├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── report.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── check-commit.yml │ └── update-github-pages.yml ├── .gitignore ├── .swiftlint.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Examples ├── Example.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ ├── IDETemplateMacros.plist │ │ └── xcschemes │ │ └── Example.xcscheme ├── Example │ ├── Info.plist │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── twemoji-cat-animated.dataset │ │ │ │ ├── Contents.json │ │ │ │ └── twemoji-cat-animated.gif │ │ │ └── twemoji-cat.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── twemoji-cat.png │ │ ├── Base.lproj │ │ │ └── LaunchScreen.storyboard │ │ └── Settings.bundle │ │ │ └── Root.plist │ └── Sources │ │ ├── AppDelegate.swift │ │ ├── Example-Bridging-Header.h │ │ ├── ImagePreviewViewController.swift │ │ ├── OSLog.swift │ │ ├── ObjcViewController.h │ │ ├── ObjcViewController.m │ │ ├── String.swift │ │ ├── SuggestViewController.swift │ │ ├── SwiftViewController.swift │ │ ├── TextEditorBridgeView.swift │ │ ├── TwitterTextEditorBridgeConfiguration.swift │ │ ├── UIColor.swift │ │ ├── UIImage.swift │ │ └── ViewController.swift └── README.md ├── LICENSE ├── Makefile ├── PROJECT ├── Package.swift ├── README.md ├── Sources └── TwitterTextEditor │ ├── Configuration.swift │ ├── Documentation.docc │ ├── Resources │ │ └── TwitterTextEditor.png │ └── TwitterTextEditor.md │ ├── EditingContent.swift │ ├── LayoutManager.swift │ ├── Logger.swift │ ├── NSAttributedString.swift │ ├── NSRange.swift │ ├── NotificationCenter.swift │ ├── Scheduler.swift │ ├── Sequence.swift │ ├── String.swift │ ├── TextAttributes.swift │ ├── TextEditorView.swift │ ├── TextEditorViewTextInputTraits.swift │ ├── TextView.swift │ ├── TextViewDelegateForwarder.swift │ ├── Tracer.swift │ ├── UIResponder.swift │ └── UITextInput.swift ├── Tests └── TwitterTextEditorTests │ ├── EditingContentTests.swift │ ├── NSRangeTests.swift │ ├── SchedulerTest.swift │ └── SequenceTest.swift └── scripts ├── docserver.rb └── verify_documentation.rb /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Twitter bug-bounty program 3 | url: https://hackerone.com/twitter 4 | about: Please report sensitive security issues via Twitter’s bug-bounty program rather than GitHub. 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Report 3 | about: Good issue reports are extremely helpful, thank you. 4 | title: One line summary of the issue 5 | 6 | --- 7 | Describe the details of the issue. 8 | 9 | **Steps to reproduce the behavior** 10 | 11 | - List all relevant steps to reproduce the observed behavior. 12 | 13 | **Expected behavior** 14 | 15 | As concisely as possible, describe the expected behavior. 16 | 17 | **Actual behavior** 18 | 19 | As concisely as possible, describe the observed behavior. 20 | 21 | **Environment** 22 | 23 | - Operating system name, version, and build number, such as “iOS 14.0.1 (18A393)”. 24 | - Hardware name and revision, such as “iPhone 11 Pro”. 25 | - Xcode version and build number, such as “12.0 (12A7209)”. 26 | - Any other dependencies, such as third-party keyboard and its version, if it’s applicable. 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Problems** 2 | 3 | Explain the context and why you’re making that change. What is the 4 | problem you’re trying to solve? 5 | In some cases there is not a problem and this can be thought of 6 | being the motivation for your change. 7 | 8 | **Solution** 9 | 10 | Describe the modifications you’ve done. 11 | 12 | **Testing** 13 | 14 | Describe the way to test your change. 15 | -------------------------------------------------------------------------------- /.github/workflows/check-commit.yml: -------------------------------------------------------------------------------- 1 | name: check-commit 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | check-commit: 9 | runs-on: macOS-12 10 | 11 | steps: 12 | - name: Use Xcode 14.2 13 | run: | 14 | sudo xcode-select -s /Applications/Xcode_14.2.app/Contents/Developer 15 | 16 | - name: Current platform versions 17 | run: | 18 | sw_vers 19 | xcodebuild -version 20 | swift --version 21 | swiftlint version 22 | 23 | - name: Checkout default branch 24 | uses: actions/checkout@v2 25 | 26 | - name: Run lint 27 | run: | 28 | make lint 29 | 30 | - name: Run test 31 | run: | 32 | make test 33 | 34 | - name: Run doc 35 | run: | 36 | make doc 37 | -------------------------------------------------------------------------------- /.github/workflows/update-github-pages.yml: -------------------------------------------------------------------------------- 1 | name: update-github-pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update-github-pages: 10 | runs-on: macOS-12 11 | 12 | env: 13 | GITHUB_PAGES_PATH: .gh-pages 14 | 15 | steps: 16 | - name: Use Xcode 14.2 17 | run: | 18 | sudo xcode-select -s /Applications/Xcode_14.2.app/Contents/Developer 19 | 20 | - name: Show versions 21 | run: | 22 | sw_vers 23 | xcodebuild -version 24 | swift --version 25 | 26 | - name: Checkout default branch 27 | uses: actions/checkout@v2 28 | 29 | - name: Checkout gh-pages branch 30 | uses: actions/checkout@v2 31 | with: 32 | ref: gh-pages 33 | path: ${{ env.GITHUB_PAGES_PATH }} 34 | 35 | - name: Update GitHub Pages 36 | run: | 37 | make ghpages 38 | 39 | - name: Commit changes 40 | run: | 41 | cd ${GITHUB_WORKSPACE}/.gh-pages 42 | git config user.name github-actions 43 | git config user.email github-actions@github.com 44 | git add . 45 | if git commit -m "Update."; then 46 | git push 47 | fi 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /*.xcodeproj 3 | /.build 4 | /.bundle 5 | /.swiftpm 6 | /Packages 7 | /build 8 | xcuserdata/ 9 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - .build 3 | 4 | disabled_rules: 5 | - cyclomatic_complexity 6 | - file_length 7 | - function_body_length 8 | - function_parameter_count 9 | - identifier_name 10 | - line_length 11 | - nesting 12 | - opening_brace 13 | - todo 14 | - type_body_length 15 | - type_name 16 | 17 | opt_in_rules: 18 | - closure_spacing 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.2 4 | 5 | - FIX: `TextViewDelegateForwarder` only forwards limited `UIScrollViewDelegate` methods (#19) 6 | 7 | ## 1.1.1 8 | 9 | - FIX: `ContentFilterScheduler` is using wrong key for cache. 10 | - Disable a workaround for the bug fixed on iOS 14.5. 11 | 12 | ## 1.1.0 13 | 14 | - Change `Root.plist` in `Settings.bundle` to have default value `NO` (#3) 15 | - FIX: Setting `TextEditorView.scrollView.delegate` may break behavior (#6) 16 | - Conform to `UITextInputTraits`. 17 | - Add `returnToEndEditingEnabled`. This allows users to implement single line text editor behavior. 18 | - Expose `inputView` and `reloadInputViews` methods (#15) 19 | - Update `UIResponder` methods. 20 | 21 | ## 1.0.0 22 | 23 | - Initial release. 24 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | We feel that a welcoming community is important and we ask that you follow Twitter’s 4 | [Open Source Code of Conduct](https://github.com/twitter/code-of-conduct/blob/master/code-of-conduct.md) 5 | in all interactions with the community. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We would love to get contributions from you. 4 | 5 | 6 | ## Issues 7 | 8 | When creating an issue please try to use the following format. 9 | Good issue reports are extremely helpful, thank you. 10 | 11 | ``` 12 | One line summary of the issue 13 | 14 | Details of the issue. 15 | 16 | **Steps to reproduce the behavior** 17 | 18 | - List all relevant steps to reproduce the observed behavior. 19 | 20 | **Expected behavior** 21 | 22 | As concisely as possible, describe the expected behavior. 23 | 24 | **Actual behavior** 25 | 26 | As concisely as possible, describe the observed behavior. 27 | 28 | **Environment** 29 | 30 | - Operating system name, version, and build number, such as “iOS 14.0.1 (18A393)”. 31 | - Hardware name and revision, such as “iPhone 11 Pro”. 32 | - Xcode version and build number, such as “12.0 (12A7209)”. 33 | - Any other dependencies, such as third-party keyboard and its version, if it’s applicable. 34 | ``` 35 | 36 | ## Pull requests 37 | 38 | If you would like to test and/or contribute please follow these instructions. 39 | 40 | ### Workflow 41 | 42 | We follow the [GitHub Flow Workflow](https://guides.github.com/introduction/flow/) 43 | 44 | 1. Fork the repository. 45 | 1. Check out the default branch. 46 | 1. Create a feature branch. 47 | 1. Write code and tests if possible for your change. 48 | 1. From your branch, make a pull request against the default branch. 49 | 1. Work with repository maintainers to get your change reviewed. 50 | 1. Wait for your change to be pulled into the default branch. 51 | 1. Delete your feature branch. 52 | 53 | ### Development 54 | 55 | It is useful to use [`Example.xcodeproj`](Examples/) for actual Twitter Text Editor development. 56 | 57 | ### Testing 58 | 59 | Use regular `XCTest` and Swift Package structure. 60 | 61 | It is highly recommended to write unit tests for applicable modules, such as the module that provides specific logics. 62 | However often it is not easy for writing unit tests for the part of user interactions on user interface components. 63 | 64 | Therefore, unlike many other cases, writing unit test is still highly recommended yet not absolutely required. 65 | Instead, write a detailed comments about problems, solution, and testing in the pull request by following the guidelines below. 66 | 67 | Use following command to run all tests. 68 | 69 | ``` 70 | $ make test 71 | ``` 72 | 73 | ### Linting 74 | 75 | It is using [SwiftLint](https://github.com/realm/SwiftLint) to lint Swift code. 76 | Install it by using such as [Homebrew](https://brew.sh/). 77 | 78 | Use following command to execute linting. 79 | 80 | ``` 81 | $ make lint 82 | ``` 83 | 84 | Use following command to fix linting problems. 85 | 86 | ``` 87 | $ make fix 88 | ``` 89 | 90 | ### Documentation 91 | 92 | It is using [Jazzy](https://github.com/realm/jazzy) to generate documents from the inline documentation comments. 93 | 94 | Use following command to install Jazzy locally and update the documents. 95 | The documents generated are placed at `.build/documentation`. 96 | 97 | ``` 98 | $ make doc 99 | ``` 100 | 101 | Use following command to run a local web server for browsing the documents. 102 | It keeps updating the documents when any source files are changed. 103 | 104 | You can browse it at . 105 | 106 | ``` 107 | $ make doc-server 108 | ``` 109 | 110 | ### Submit pull requests 111 | 112 | Files should be exempt of trailing spaces, linted and passed all unit tests. 113 | 114 | Pull request comments should be formatted to a width no greater than 72 characters. 115 | 116 | We adhere to a specific format for commit messages. 117 | Please write your commit messages along these guidelines. 118 | 119 | ``` 120 | One line description of your change (less than 72 characters) 121 | 122 | **Problems** 123 | 124 | Explain the context and why you’re making that change. What is the 125 | problem you’re trying to solve? 126 | In some cases there is not a problem and this can be thought of 127 | being the motivation for your change. 128 | 129 | **Solution** 130 | 131 | Describe the modifications you’ve done. 132 | 133 | **Testing** 134 | 135 | Describe the way to test your change. 136 | ``` 137 | 138 | Some important notes regarding the summary line. 139 | 140 | * Describe what was done; not the result. 141 | * Use the active voice. 142 | * Use the present tense. 143 | * Capitalize properly. 144 | * Do not end in a period. This is a title or subject. 145 | 146 | 147 | ### Code review 148 | 149 | This repository on GitHub is kept in sync with an internal repository at Twitter. 150 | For the most part this process should be transparent to the repository users, but it does have some implications for how pull requests are merged into the codebase. 151 | 152 | When you submit a pull request on GitHub, it will be reviewed by the community (both inside and outside of Twitter), and once the changes are approved, your commits will be brought into Twitter’s internal system for additional testing. 153 | Once the changes are merged internally, they will be pushed back to GitHub with the next sync. 154 | 155 | This process means that the pull request will not be merged in the usual way. 156 | Instead a member of the repository owner will post a message in the pull request thread when your changes have made their way back to GitHub, and the pull request will be closed. 157 | The changes in the pull request will be collapsed into a single commit, but the authorship metadata will be preserved. 158 | 159 | 160 | ## License 161 | 162 | By contributing your code, you agree to license your contribution under the terms of [the Apache License, Version 2.0](LICENSE). 163 | -------------------------------------------------------------------------------- /Examples/Example.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // ___FILENAME___ 8 | // ___TARGETNAME___ 9 | // 10 | // Copyright ___YEAR___ ___ORGANIZATIONNAME___ 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | 14 | 15 | -------------------------------------------------------------------------------- /Examples/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Examples/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Examples/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Examples/Example/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Example/Resources/Assets.xcassets/twemoji-cat-animated.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "twemoji-cat-animated.gif", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Examples/Example/Resources/Assets.xcassets/twemoji-cat-animated.dataset/twemoji-cat-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twitter/TwitterTextEditor/39256a958ade4a682e43f21f22140a5b3bbe280a/Examples/Example/Resources/Assets.xcassets/twemoji-cat-animated.dataset/twemoji-cat-animated.gif -------------------------------------------------------------------------------- /Examples/Example/Resources/Assets.xcassets/twemoji-cat.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "twemoji-cat.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/Example/Resources/Assets.xcassets/twemoji-cat.imageset/twemoji-cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twitter/TwitterTextEditor/39256a958ade4a682e43f21f22140a5b3bbe280a/Examples/Example/Resources/Assets.xcassets/twemoji-cat.imageset/twemoji-cat.png -------------------------------------------------------------------------------- /Examples/Example/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Examples/Example/Resources/Settings.bundle/Root.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | StringsTable 6 | Root 7 | PreferenceSpecifiers 8 | 9 | 10 | Type 11 | PSToggleSwitchSpecifier 12 | Title 13 | Use custom drop interaction 14 | Key 15 | use_custom_drop_interaction 16 | DefaultValue 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Examples/Example/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import KeyboardGuide 11 | import TwitterTextEditor 12 | import UIKit 13 | 14 | @UIApplicationMain 15 | final class AppDelegate: UIResponder, UIApplicationDelegate { 16 | var window: UIWindow? 17 | 18 | func application(_ application: UIApplication, 19 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool 20 | { 21 | KeyboardGuide.shared.activate() 22 | 23 | // For Objective-C implementation, you can use `TwitterTextEditorBridgeConfiguration`. 24 | // See `TwitterTextEditorBridgeConfiguration.swift`. 25 | let configuration = TwitterTextEditor.Configuration.shared 26 | configuration.logger = OSDefaultLogger() 27 | configuration.tracer = OSDefaultTracer() 28 | configuration.attributeNamesDescribedForAttributedStringShortDescription = [ 29 | .font, 30 | .foregroundColor, 31 | .backgroundColor, 32 | .suffixedAttachment 33 | ] 34 | 35 | let window = UIWindow(frame: UIScreen.main.bounds) 36 | 37 | let viewController = ViewController() 38 | let navigationController = UINavigationController(rootViewController: viewController) 39 | 40 | window.rootViewController = navigationController 41 | window.makeKeyAndVisible() 42 | self.window = window 43 | 44 | return true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Examples/Example/Sources/Example-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Example-Bridging-Header.h 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | // Use this file to import your target's public headers that you would like to expose to Swift. 9 | // 10 | 11 | #import "ObjcViewController.h" 12 | -------------------------------------------------------------------------------- /Examples/Example/Sources/ImagePreviewViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImagePreviewViewController.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | final class ImagePreviewViewController: UIViewController { 13 | private let image: UIImage 14 | private let action: (() -> Void)? 15 | 16 | private var titleLabel: UILabel? 17 | 18 | init(image: UIImage, action: (() -> Void)? = nil) { 19 | self.image = image 20 | self.action = action 21 | 22 | super.init(nibName: nil, bundle: nil) 23 | } 24 | 25 | @available(*, unavailable) 26 | required init?(coder: NSCoder) { 27 | fatalError() 28 | } 29 | 30 | // MARK: - UIViewController 31 | 32 | override func viewDidLoad() { 33 | view.backgroundColor = .clear 34 | 35 | let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(viewDidTap(_:))) 36 | view.addGestureRecognizer(tapGestureRecognizer) 37 | 38 | var constraints = [NSLayoutConstraint]() 39 | defer { 40 | NSLayoutConstraint.activate(constraints) 41 | } 42 | 43 | let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .prominent)) 44 | view.addSubview(blurView) 45 | blurView.translatesAutoresizingMaskIntoConstraints = false 46 | constraints.append(blurView.topAnchor.constraint(equalTo: view.topAnchor)) 47 | constraints.append(blurView.leadingAnchor.constraint(equalTo: view.leadingAnchor)) 48 | constraints.append(blurView.bottomAnchor.constraint(equalTo: view.bottomAnchor)) 49 | constraints.append(blurView.trailingAnchor.constraint(equalTo: view.trailingAnchor)) 50 | 51 | let titleLabel = UILabel() 52 | titleLabel.text = title 53 | titleLabel.font = .boldSystemFont(ofSize: 18.0) 54 | blurView.contentView.addSubview(titleLabel) 55 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 56 | titleLabel.setContentCompressionResistancePriority(.required, for: .vertical) 57 | titleLabel.setContentHuggingPriority(.required, for: .vertical) 58 | constraints.append(titleLabel.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 20.0)) 59 | constraints.append(titleLabel.centerXAnchor.constraint(equalTo: view.layoutMarginsGuide.centerXAnchor)) 60 | self.titleLabel = titleLabel 61 | 62 | let imageView = UIImageView(image: image) 63 | imageView.contentMode = .scaleAspectFit 64 | blurView.contentView.addSubview(imageView) 65 | imageView.translatesAutoresizingMaskIntoConstraints = false 66 | constraints.append(imageView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20.0)) 67 | constraints.append(imageView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor)) 68 | constraints.append(imageView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)) 69 | constraints.append(imageView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor)) 70 | } 71 | 72 | override var title: String? { 73 | didSet { 74 | self.titleLabel?.text = title 75 | } 76 | } 77 | 78 | // MARK: - Action 79 | 80 | @objc 81 | public func viewDidTap(_ tapGestureRecognizer: UITapGestureRecognizer) { 82 | action?() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Examples/Example/Sources/OSLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OSLog.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import TwitterTextEditor 11 | import os 12 | 13 | struct OSDefaultLogger: TwitterTextEditor.Logger { 14 | func log(type: TwitterTextEditor.LogType, 15 | file: StaticString, 16 | line: Int, 17 | function: StaticString, 18 | message: @autoclosure () -> String?) 19 | { 20 | let osLogType: OSLogType 21 | switch type { 22 | case .`default`: 23 | osLogType = .default 24 | case .info: 25 | osLogType = .info 26 | case .debug: 27 | osLogType = .debug 28 | case .error: 29 | osLogType = .error 30 | case .fault: 31 | osLogType = .fault 32 | } 33 | 34 | if let message = message() { 35 | os_log("%{public}@:%d in %{public}@: %{public}@", 36 | type: osLogType, 37 | NSString(stringLiteral: file).lastPathComponent, // swiftlint:disable:this compiler_protocol_init 38 | line, String(describing: function), 39 | message) 40 | } else { 41 | os_log("%{public}@:%d in %{public}@", 42 | type: osLogType, 43 | NSString(stringLiteral: file).lastPathComponent, // swiftlint:disable:this compiler_protocol_init 44 | line, 45 | String(describing: function)) 46 | } 47 | } 48 | } 49 | 50 | struct OSSignpost: TwitterTextEditor.Signpost { 51 | // TODO: Remove `os_signpost` wrapper when it drops iOS 11 support. 52 | @available(iOS 12.0, *) 53 | private struct OSSignpost: TwitterTextEditor.Signpost { 54 | private let log: OSLog 55 | private let name: StaticString 56 | private let message: String? 57 | 58 | private let signpostID: OSSignpostID 59 | 60 | init(log: OSLog, 61 | name: StaticString, 62 | message: String? = nil) 63 | { 64 | self.log = log 65 | self.name = name 66 | self.message = message 67 | 68 | signpostID = OSSignpostID(log: log) 69 | } 70 | 71 | private func signpost(_ type: OSSignpostType) { 72 | if let message = message { 73 | os_signpost(type, log: log, name: name, signpostID: signpostID, "%@", message) 74 | } else { 75 | os_signpost(type, log: log, name: name, signpostID: signpostID) 76 | } 77 | } 78 | 79 | func begin() { 80 | signpost(.begin) 81 | } 82 | 83 | func end() { 84 | signpost(.end) 85 | } 86 | 87 | func event() { 88 | signpost(.event) 89 | } 90 | } 91 | 92 | private let signpost: TwitterTextEditor.Signpost? 93 | 94 | init(log: OSLog, 95 | name: StaticString, 96 | message: String? = nil) 97 | { 98 | if #available(iOS 12.0, *) { 99 | signpost = OSSignpost(log: log, name: name, message: message) 100 | } else { 101 | signpost = nil 102 | } 103 | } 104 | 105 | func begin() { 106 | signpost?.begin() 107 | } 108 | 109 | func end() { 110 | signpost?.end() 111 | } 112 | 113 | func event() { 114 | signpost?.event() 115 | } 116 | } 117 | 118 | struct OSDefaultTracer: TwitterTextEditor.Tracer { 119 | func signpost(name: StaticString, 120 | file: StaticString, 121 | line: Int, 122 | function: StaticString, 123 | message: @autoclosure () -> String?) -> TwitterTextEditor.Signpost 124 | { 125 | OSSignpost(log: .default, name: name, message: message()) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Examples/Example/Sources/ObjcViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // ObjcViewController.h 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | @import Foundation; 10 | @import UIKit; 11 | 12 | NS_ASSUME_NONNULL_BEGIN 13 | 14 | @class ObjcViewController; 15 | 16 | @protocol ObjcViewControllerDelegate 17 | 18 | @optional 19 | - (void)objcViewControllerDidTapDone:(ObjcViewController *)objcViewController; 20 | 21 | @end 22 | 23 | @interface ObjcViewController : UIViewController 24 | 25 | @property (nonatomic, weak, nullable) id delegate; 26 | 27 | - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE; 28 | - (nullable instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; 29 | 30 | - (instancetype)init NS_DESIGNATED_INITIALIZER; 31 | 32 | @end 33 | 34 | NS_ASSUME_NONNULL_END 35 | -------------------------------------------------------------------------------- /Examples/Example/Sources/ObjcViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // ObjcViewController.m 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | 10 | #import "Example-Swift.h" 11 | #import "ObjcViewController.h" 12 | 13 | @import KeyboardGuide; 14 | 15 | NS_ASSUME_NONNULL_BEGIN 16 | 17 | @interface ObjcViewController () 18 | 19 | @property (nonatomic, nullable) TextEditorBridgeView *textEditorView; 20 | 21 | @end 22 | 23 | @implementation ObjcViewController 24 | 25 | - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil 26 | { 27 | [self doesNotRecognizeSelector:_cmd]; 28 | abort(); 29 | } 30 | 31 | - (nullable instancetype)initWithCoder:(NSCoder *)coder 32 | { 33 | [self doesNotRecognizeSelector:_cmd]; 34 | abort(); 35 | } 36 | 37 | - (instancetype)init 38 | { 39 | if (self = [super initWithNibName:nil bundle:nil]) { 40 | self.title = @"Objective-C example"; 41 | 42 | UIBarButtonItem * const refreshBarButtonItem = 43 | [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemRefresh 44 | target:self 45 | action:@selector(refreshBarButtonItemDidTap:)]; 46 | self.navigationItem.leftBarButtonItems = @[refreshBarButtonItem]; 47 | UIBarButtonItem * const doneBarButtonItem = 48 | [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone 49 | target:self 50 | action:@selector(doneBarButtonItemDidTap:)]; 51 | self.navigationItem.rightBarButtonItems = @[doneBarButtonItem]; 52 | } 53 | return self; 54 | } 55 | 56 | // MARK: - Actions 57 | 58 | - (void)refreshBarButtonItemDidTap:(id)sender 59 | { 60 | self.textEditorView.isEditing = NO; 61 | self.textEditorView.text = @""; 62 | } 63 | 64 | - (void)doneBarButtonItemDidTap:(id)sender 65 | { 66 | id const delegate = self.delegate; 67 | if ([delegate respondsToSelector:@selector(objcViewControllerDidTapDone:)]) { 68 | [delegate objcViewControllerDidTapDone:self]; 69 | } 70 | } 71 | 72 | // MARK: - UIViewController 73 | 74 | - (void)viewDidLoad 75 | { 76 | [super viewDidLoad]; 77 | 78 | self.view.backgroundColor = UIColor.defaultBackgroundColor; 79 | 80 | NSMutableArray * const constraints = [[NSMutableArray alloc] init]; 81 | 82 | TextEditorBridgeView * const textEditorView = [[TextEditorBridgeView alloc] init]; 83 | textEditorView.delegate = self; 84 | 85 | textEditorView.layer.borderColor = UIColor.defaultBorderColor.CGColor; 86 | textEditorView.layer.borderWidth = 1.0; 87 | 88 | textEditorView.font = [UIFont systemFontOfSize:20.0]; 89 | 90 | [self.view addSubview:textEditorView]; 91 | 92 | textEditorView.translatesAutoresizingMaskIntoConstraints = NO; 93 | [constraints addObject:[textEditorView.topAnchor constraintEqualToAnchor:self.view.layoutMarginsGuide.topAnchor constant: 20.0]]; 94 | [constraints addObject:[textEditorView.leadingAnchor constraintEqualToAnchor:self.view.layoutMarginsGuide.leadingAnchor]]; 95 | [constraints addObject:[textEditorView.bottomAnchor constraintEqualToAnchor:self.view.layoutMarginsGuide.bottomAnchor]]; 96 | [constraints addObject:[textEditorView.trailingAnchor constraintEqualToAnchor:self.view.layoutMarginsGuide.trailingAnchor]]; 97 | 98 | self.textEditorView = textEditorView; 99 | 100 | // This view is used to call `layoutSubviews` when keyboard safe area is changed 101 | // to manually change scroll view content insets. 102 | // See `viewDidLayoutSubviews`. 103 | UIView * const keyboardSafeAreaRelativeLayoutView = [[UIView alloc] init]; 104 | [self.view addSubview:keyboardSafeAreaRelativeLayoutView]; 105 | keyboardSafeAreaRelativeLayoutView.translatesAutoresizingMaskIntoConstraints = NO; 106 | [constraints addObject:[keyboardSafeAreaRelativeLayoutView.bottomAnchor constraintEqualToAnchor:self.view.kbg_keyboardSafeArea.layoutGuide.bottomAnchor]]; 107 | 108 | [NSLayoutConstraint activateConstraints:constraints]; 109 | } 110 | 111 | - (void)viewDidLayoutSubviews 112 | { 113 | [super viewDidLayoutSubviews]; 114 | 115 | const CGFloat bottomInset = self.view.kbg_keyboardSafeArea.insets.bottom - self.view.layoutMargins.bottom; 116 | 117 | UIEdgeInsets contentInset = self.textEditorView.scrollView.contentInset; 118 | contentInset.bottom = bottomInset; 119 | self.textEditorView.scrollView.contentInset = contentInset; 120 | 121 | if (@available(iOS 11.1, *)) { 122 | UIEdgeInsets verticalScrollIndicatorInsets = self.textEditorView.scrollView.verticalScrollIndicatorInsets; 123 | verticalScrollIndicatorInsets.bottom = bottomInset; 124 | self.textEditorView.scrollView.verticalScrollIndicatorInsets = verticalScrollIndicatorInsets; 125 | } else { 126 | UIEdgeInsets scrollIndicatorInsets = self.textEditorView.scrollView.scrollIndicatorInsets; 127 | scrollIndicatorInsets.bottom = bottomInset; 128 | self.textEditorView.scrollView.scrollIndicatorInsets = scrollIndicatorInsets; 129 | } 130 | } 131 | 132 | // MARK: - TextEditorBridgeViewDelegate 133 | 134 | - (void)textEditorBridgeView:(TextEditorBridgeView *)textEditorBridgeView 135 | updateAttributedString:(NSAttributedString *)attributedString 136 | completion:(void (^)(NSAttributedString * _Nullable))completion 137 | { 138 | NSMutableAttributedString * const text = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; 139 | const NSRange range = NSMakeRange(0, text.string.length); 140 | 141 | [text addAttribute:NSForegroundColorAttributeName value:UIColor.defaultTextColor range:range]; 142 | 143 | NSRegularExpression * const regexp = [[NSRegularExpression alloc] initWithPattern:@"#[^\\s]+" options:0 error:nil]; 144 | [regexp enumerateMatchesInString:text.string 145 | options:0 146 | range:range 147 | usingBlock:^(NSTextCheckingResult * _Nullable result, NSMatchingFlags flags, BOOL * _Nonnull stop) { 148 | if (!result) { 149 | return; 150 | } 151 | const NSRange matchedRange = result.range; 152 | [text addAttribute:NSForegroundColorAttributeName value:UIColor.systemBlueColor range:matchedRange]; 153 | }]; 154 | 155 | completion(text); 156 | } 157 | 158 | @end 159 | 160 | NS_ASSUME_NONNULL_END 161 | -------------------------------------------------------------------------------- /Examples/Example/Sources/String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | @inlinable 13 | var length: Int { 14 | (self as NSString).length 15 | } 16 | 17 | @inlinable 18 | func substring(with range: NSRange) -> String { 19 | (self as NSString).substring(with: range) 20 | } 21 | 22 | func substring(with result: NSTextCheckingResult, at index: Int) -> String? { 23 | guard index < result.numberOfRanges else { 24 | return nil 25 | } 26 | let range = result.range(at: index) 27 | guard range.location != NSNotFound else { 28 | return nil 29 | } 30 | return substring(with: result.range(at: index)) 31 | } 32 | 33 | func firstMatch(pattern: String, 34 | options: NSRegularExpression.Options = [], 35 | range: NSRange? = nil) -> NSTextCheckingResult? 36 | { 37 | guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { 38 | return nil 39 | } 40 | let range = range ?? NSRange(location: 0, length: length) 41 | return regularExpression.firstMatch(in: self, options: [], range: range) 42 | } 43 | 44 | func matches(pattern: String, 45 | options: NSRegularExpression.Options = [], 46 | range: NSRange? = nil) -> [NSTextCheckingResult] 47 | { 48 | guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { 49 | return [] 50 | } 51 | let range = range ?? NSRange(location: 0, length: length) 52 | return regularExpression.matches(in: self, options: [], range: range) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Examples/Example/Sources/SuggestViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuggestViewController.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol SuggestViewControllerDelegate: AnyObject { 13 | func suggestViewController(_ viewController: SuggestViewController, didSelectSuggestedString suggestString: String) 14 | } 15 | 16 | final class SuggestViewController: UIViewController { 17 | weak var delegate: SuggestViewControllerDelegate? 18 | 19 | private var tableView: UITableView? 20 | 21 | init() { 22 | super.init(nibName: nil, bundle: nil) 23 | } 24 | 25 | @available(*, unavailable) 26 | required init?(coder: NSCoder) { 27 | fatalError() 28 | } 29 | 30 | var suggests: [String] = [] { 31 | didSet { 32 | guard oldValue != suggests else { 33 | return 34 | } 35 | tableView?.reloadData() 36 | } 37 | } 38 | 39 | private let suggestedStringCellReuseIdentifier = "suggestedStringCellReuseIdentifier" 40 | 41 | override func viewDidLoad() { 42 | super.viewDidLoad() 43 | 44 | view.backgroundColor = .defaultBackground 45 | 46 | var constraints = [NSLayoutConstraint]() 47 | defer { 48 | NSLayoutConstraint.activate(constraints) 49 | } 50 | 51 | let tableView = UITableView() 52 | tableView.contentInsetAdjustmentBehavior = .never 53 | tableView.dataSource = self 54 | tableView.delegate = self 55 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: suggestedStringCellReuseIdentifier) 56 | 57 | view.addSubview(tableView) 58 | 59 | tableView.translatesAutoresizingMaskIntoConstraints = false 60 | constraints.append(tableView.topAnchor.constraint(equalTo: view.topAnchor)) 61 | constraints.append(tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor)) 62 | constraints.append(tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)) 63 | constraints.append(tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)) 64 | } 65 | } 66 | 67 | // MARK: - UITableViewDataSource 68 | 69 | extension SuggestViewController: UITableViewDataSource { 70 | func numberOfSections(in tableView: UITableView) -> Int { 71 | 1 72 | } 73 | 74 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 75 | suggests.count 76 | } 77 | 78 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 79 | let cell = tableView.dequeueReusableCell(withIdentifier: suggestedStringCellReuseIdentifier, for: indexPath) 80 | let suggestedString = suggests[indexPath.row] 81 | cell.textLabel?.text = suggestedString 82 | return cell 83 | } 84 | } 85 | 86 | // MARK: - UITableViewDelegate 87 | 88 | extension SuggestViewController: UITableViewDelegate { 89 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 90 | let suggestedString = suggests[indexPath.row] 91 | delegate?.suggestViewController(self, didSelectSuggestedString: suggestedString) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Examples/Example/Sources/SwiftViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftViewController.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import KeyboardGuide 11 | import MobileCoreServices 12 | import TwitterTextEditor 13 | import UIKit 14 | 15 | protocol SwiftViewControllerDelegate: AnyObject { 16 | func swiftViewControllerDidTapDone(_ swiftViewController: SwiftViewController) 17 | } 18 | 19 | private final class BlockPasteObserver: TextEditorViewPasteObserver { 20 | var acceptableTypeIdentifiers: [String] 21 | 22 | private var canPaste: (NSItemProvider) -> Bool 23 | 24 | func canPasteItemProvider(_ itemProvider: NSItemProvider) -> Bool { 25 | canPaste(itemProvider) 26 | } 27 | 28 | private var transform: (NSItemProvider, TextEditorViewPasteObserverTransformCompletion) -> Void 29 | 30 | func transformItemProvider(_ itemProvider: NSItemProvider, completion: TextEditorViewPasteObserverTransformCompletion) { 31 | transform(itemProvider, completion) 32 | } 33 | 34 | init(acceptableTypeIdentifiers: [String], 35 | canPaste: @escaping (NSItemProvider) -> Bool, 36 | transform: @escaping (NSItemProvider, TextEditorViewPasteObserverTransformCompletion) -> Void 37 | ) { 38 | self.acceptableTypeIdentifiers = acceptableTypeIdentifiers 39 | self.canPaste = canPaste 40 | self.transform = transform 41 | } 42 | } 43 | 44 | final class SwiftViewController: UIViewController { 45 | weak var delegate: SwiftViewControllerDelegate? 46 | 47 | private var textEditorView: TextEditorView? 48 | private var dropIndicationView: UIView? 49 | 50 | private var attachmentViews: [UIView] = [] 51 | 52 | private var attachmentStillImage: UIImage? 53 | private var attachmentAnimatedImage: UIImage? 54 | 55 | init() { 56 | super.init(nibName: nil, bundle: nil) 57 | 58 | title = "Swift example" 59 | 60 | let refreshBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, 61 | target: self, 62 | action: #selector(refreshBarButtonItemDidTap(_:))) 63 | navigationItem.leftBarButtonItems = [refreshBarButtonItem] 64 | 65 | let doneBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, 66 | target: self, 67 | action: #selector(doneBarButtonItemDidTap(_:))) 68 | navigationItem.rightBarButtonItems = [doneBarButtonItem] 69 | 70 | attachmentStillImage = UIImage(named: "twemoji-cat") 71 | attachmentAnimatedImage = .animatedImage(named: "twemoji-cat-animated", duration: 1.2) 72 | } 73 | 74 | @available(*, unavailable) 75 | required init?(coder: NSCoder) { 76 | fatalError() 77 | } 78 | 79 | var useCustomDropInteraction: Bool = false { 80 | didSet { 81 | guard oldValue != useCustomDropInteraction else { 82 | return 83 | } 84 | updateCustomDropInteraction() 85 | } 86 | } 87 | 88 | private var customDropInteraction: UIDropInteraction? 89 | 90 | private func updateCustomDropInteraction() { 91 | guard isViewLoaded, let textEditorView = textEditorView else { 92 | return 93 | } 94 | 95 | if useCustomDropInteraction { 96 | if customDropInteraction == nil { 97 | let dropInteraction = UIDropInteraction(delegate: self) 98 | view.addInteraction(dropInteraction) 99 | self.customDropInteraction = dropInteraction 100 | } 101 | 102 | // To disable drop interaction on the text editor view, set `isDropInteractionEnabled` to `false`. 103 | // Users can still drag and drop texts inside text editor view. 104 | textEditorView.isDropInteractionEnabled = false 105 | } else { 106 | if let dropInteraction = customDropInteraction { 107 | view.removeInteraction(dropInteraction) 108 | self.customDropInteraction = nil 109 | } 110 | textEditorView.isDropInteractionEnabled = true 111 | } 112 | } 113 | 114 | // MARK: - Actions 115 | 116 | @objc 117 | private func refreshBarButtonItemDidTap(_ sender: Any) { 118 | textEditorView?.isEditing = false 119 | textEditorView?.text = "" 120 | } 121 | 122 | @objc 123 | private func doneBarButtonItemDidTap(_ sender: Any) { 124 | delegate?.swiftViewControllerDidTapDone(self) 125 | } 126 | 127 | // MARK: - UIViewController 128 | 129 | override func viewDidLoad() { 130 | super.viewDidLoad() 131 | 132 | view.backgroundColor = .defaultBackground 133 | 134 | var constraints = [NSLayoutConstraint]() 135 | defer { 136 | NSLayoutConstraint.activate(constraints) 137 | } 138 | 139 | let textEditorView = TextEditorView() 140 | 141 | textEditorView.layer.borderColor = UIColor.defaultBorder.cgColor 142 | textEditorView.layer.borderWidth = 1.0 143 | 144 | textEditorView.changeObserver = self 145 | textEditorView.editingContentDelegate = self 146 | textEditorView.textAttributesDelegate = self 147 | 148 | textEditorView.font = .systemFont(ofSize: 20.0) 149 | textEditorView.placeholderText = "This is an example of place holder text that can be truncated." 150 | 151 | textEditorView.pasteObservers = [ 152 | BlockPasteObserver( 153 | acceptableTypeIdentifiers: [kUTTypeImage as String], 154 | canPaste: { _ in 155 | true 156 | }, 157 | transform: { [weak self] itemProvider, reply in 158 | itemProvider.loadDataRepresentation(forTypeIdentifier: kUTTypeImage as String) { [weak self] data, _ in 159 | if let data = data, let image = UIImage(data: data) { 160 | // Called on an arbitrary background queue. 161 | DispatchQueue.main.async { 162 | let imagePreviewViewController = ImagePreviewViewController(image: image) { [weak self] in 163 | self?.dismiss(animated: true, completion: nil) 164 | } 165 | imagePreviewViewController.title = "Pasted" 166 | self?.present(imagePreviewViewController, animated: true) 167 | } 168 | } 169 | reply.transformed() 170 | } 171 | } 172 | ) 173 | ] 174 | 175 | view.addSubview(textEditorView) 176 | 177 | textEditorView.translatesAutoresizingMaskIntoConstraints = false 178 | constraints.append(textEditorView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 20.0)) 179 | constraints.append(textEditorView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor)) 180 | constraints.append(textEditorView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)) 181 | constraints.append(textEditorView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor)) 182 | 183 | self.textEditorView = textEditorView 184 | 185 | let dropIndicationView = UIView() 186 | dropIndicationView.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.5) 187 | dropIndicationView.isHidden = true 188 | 189 | view.addSubview(dropIndicationView) 190 | 191 | dropIndicationView.translatesAutoresizingMaskIntoConstraints = false 192 | constraints.append(dropIndicationView.topAnchor.constraint(equalTo: view.topAnchor)) 193 | constraints.append(dropIndicationView.leadingAnchor.constraint(equalTo: view.leadingAnchor)) 194 | constraints.append(dropIndicationView.bottomAnchor.constraint(equalTo: view.bottomAnchor)) 195 | constraints.append(dropIndicationView.trailingAnchor.constraint(equalTo: view.trailingAnchor)) 196 | 197 | self.dropIndicationView = dropIndicationView 198 | 199 | let dropIndicationLabel = UILabel() 200 | dropIndicationLabel.text = "Drop here" 201 | dropIndicationLabel.textColor = .white 202 | dropIndicationLabel.font = .systemFont(ofSize: 40.0) 203 | 204 | dropIndicationView.addSubview(dropIndicationLabel) 205 | 206 | dropIndicationLabel.translatesAutoresizingMaskIntoConstraints = false 207 | constraints.append(dropIndicationLabel.centerXAnchor.constraint(equalTo: dropIndicationView.centerXAnchor)) 208 | constraints.append(dropIndicationLabel.centerYAnchor.constraint(equalTo: dropIndicationView.centerYAnchor)) 209 | 210 | updateCustomDropInteraction() 211 | 212 | // This view is used to call `layoutSubviews()` when keyboard safe area is changed 213 | // to manually change scroll view content insets. 214 | // See `viewDidLayoutSubviews()`. 215 | let keyboardSafeAreaRelativeLayoutView = UIView() 216 | view.addSubview(keyboardSafeAreaRelativeLayoutView) 217 | keyboardSafeAreaRelativeLayoutView.translatesAutoresizingMaskIntoConstraints = false 218 | constraints.append(keyboardSafeAreaRelativeLayoutView.bottomAnchor.constraint(equalTo: view.keyboardSafeArea.layoutGuide.bottomAnchor)) 219 | } 220 | 221 | override func viewDidLayoutSubviews() { 222 | super.viewDidLayoutSubviews() 223 | 224 | guard let textEditorView = textEditorView else { 225 | return 226 | } 227 | 228 | let bottomInset = view.keyboardSafeArea.insets.bottom - view.layoutMargins.bottom 229 | textEditorView.scrollView.contentInset.bottom = bottomInset 230 | if #available(iOS 11.1, *) { 231 | textEditorView.scrollView.verticalScrollIndicatorInsets.bottom = bottomInset 232 | } else { 233 | textEditorView.scrollView.scrollIndicatorInsets.bottom = bottomInset 234 | } 235 | } 236 | 237 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 238 | super.traitCollectionDidChange(previousTraitCollection) 239 | 240 | // This is an example call for supporting accessibility contrast change to recall 241 | // `textEditorView(_:updateAttributedString:completion:)`. 242 | textEditorView?.setNeedsUpdateTextAttributes() 243 | } 244 | 245 | // MARK: - Suggests 246 | 247 | private var suggestViewController: SuggestViewController? 248 | 249 | private func presentSuggests(_ suggests: [String]) { 250 | guard suggestViewController == nil else { 251 | return 252 | } 253 | 254 | let suggestViewController = SuggestViewController() 255 | suggestViewController.delegate = self 256 | suggestViewController.suggests = suggests 257 | 258 | addChild(suggestViewController) 259 | 260 | var constraints = [NSLayoutConstraint]() 261 | 262 | suggestViewController.view.layer.borderColor = UIColor.defaultBorder.cgColor 263 | suggestViewController.view.layer.borderWidth = 1.0 264 | 265 | view.addSubview(suggestViewController.view) 266 | 267 | suggestViewController.view.translatesAutoresizingMaskIntoConstraints = false 268 | constraints.append(suggestViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor)) 269 | constraints.append(suggestViewController.view.bottomAnchor.constraint(equalTo: view.keyboardSafeArea.layoutGuide.bottomAnchor)) 270 | constraints.append(suggestViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)) 271 | constraints.append(suggestViewController.view.heightAnchor.constraint(equalTo: view.keyboardSafeArea.layoutGuide.heightAnchor, multiplier: 0.5)) 272 | 273 | NSLayoutConstraint.activate(constraints) 274 | 275 | suggestViewController.didMove(toParent: self) 276 | 277 | self.suggestViewController = suggestViewController 278 | } 279 | 280 | private func dismissSuggests() { 281 | guard let suggestViewController = suggestViewController else { 282 | return 283 | } 284 | 285 | suggestViewController.willMove(toParent: nil) 286 | suggestViewController.view.removeFromSuperview() 287 | suggestViewController.removeFromParent() 288 | 289 | self.suggestViewController = nil 290 | } 291 | } 292 | 293 | // MARK: - UIDropInteractionDelegate 294 | 295 | extension SwiftViewController: UIDropInteractionDelegate { 296 | func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool { 297 | if let textEditorView = textEditorView, let localDragSession = session.localDragSession { 298 | return !textEditorView.isDraggingText(of: localDragSession) 299 | } 300 | return session.items.contains { item in 301 | item.itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) 302 | } 303 | } 304 | 305 | func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal { 306 | .init(operation: .copy) 307 | } 308 | 309 | func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnter session: UIDropSession) { 310 | dropIndicationView?.isHidden = false 311 | } 312 | 313 | func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit session: UIDropSession) { 314 | dropIndicationView?.isHidden = true 315 | } 316 | 317 | func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnd session: UIDropSession) { 318 | dropIndicationView?.isHidden = true 319 | } 320 | 321 | func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) { 322 | guard let item = session.items.first else { 323 | return 324 | } 325 | 326 | let itemProvider = item.itemProvider 327 | itemProvider.loadDataRepresentation(forTypeIdentifier: kUTTypeImage as String) { [weak self] data, _ in 328 | if let data = data, let image = UIImage(data: data) { 329 | // Called on an arbitrary background queue. 330 | DispatchQueue.main.async { 331 | let imagePreviewViewController = ImagePreviewViewController(image: image) { [weak self] in 332 | self?.dismiss(animated: true, completion: nil) 333 | } 334 | imagePreviewViewController.title = "Dropped" 335 | self?.present(imagePreviewViewController, animated: true) 336 | } 337 | } 338 | } 339 | } 340 | } 341 | 342 | // MARK: - TextEditorViewChangeObserver 343 | 344 | extension SwiftViewController: TextEditorViewChangeObserver { 345 | func textEditorView(_ textEditorView: TextEditorView, 346 | didChangeWithChangeResult changeResult: TextEditorViewChangeResult) 347 | { 348 | let selectedRange = textEditorView.selectedRange 349 | if selectedRange.length != 0 { 350 | dismissSuggests() 351 | return 352 | } 353 | 354 | let precedingText = textEditorView.text.substring(with: NSRange(location: 0, length: selectedRange.upperBound)) 355 | if precedingText.firstMatch(pattern: "#[^\\s]*\\z") != nil { 356 | if changeResult.isTextChanged { 357 | presentSuggests([ 358 | "meow", 359 | "cat", 360 | "wowcat", 361 | "かわいい🐱" 362 | ]) 363 | } 364 | } else { 365 | dismissSuggests() 366 | } 367 | } 368 | } 369 | 370 | // MARK: - TextEditorViewEditingContentDelegate 371 | 372 | private struct EditingContent: TextEditorViewEditingContent { 373 | var text: String 374 | var selectedRange: NSRange 375 | } 376 | 377 | private extension TextEditorViewEditingContent { 378 | func filter(_ isIncluded: (Unicode.Scalar) -> Bool) -> TextEditorViewEditingContent { 379 | var filteredUnicodeScalars = String.UnicodeScalarView() 380 | 381 | var index = 0 382 | var updatedSelectedRange = selectedRange 383 | 384 | for unicodeScalar in text.unicodeScalars { 385 | if isIncluded(unicodeScalar) { 386 | filteredUnicodeScalars.append(unicodeScalar) 387 | index += unicodeScalar.utf16.count 388 | } else { 389 | let replacingRange = NSRange(location: index, length: unicodeScalar.utf16.count) 390 | updatedSelectedRange = updatedSelectedRange.movedByReplacing(range: replacingRange, length: 0) 391 | } 392 | } 393 | 394 | return EditingContent(text: String(filteredUnicodeScalars), selectedRange: updatedSelectedRange) 395 | } 396 | } 397 | 398 | extension SwiftViewController: TextEditorViewEditingContentDelegate { 399 | func textEditorView(_ textEditorView: TextEditorView, 400 | updateEditingContent editingContent: TextEditorViewEditingContent) -> TextEditorViewEditingContent? 401 | { 402 | editingContent.filter { unicodeScalar in 403 | // Filtering any BiDi control characters out. 404 | !unicodeScalar.properties.isBidiControl 405 | } 406 | } 407 | } 408 | 409 | // MARK: - TextEditorViewTextAttributesDelegate 410 | 411 | extension SwiftViewController: TextEditorViewTextAttributesDelegate { 412 | func textEditorView(_ textEditorView: TextEditorView, 413 | updateAttributedString attributedString: NSAttributedString, 414 | completion: @escaping (NSAttributedString?) -> Void) 415 | { 416 | DispatchQueue.global().async { 417 | let string = attributedString.string 418 | let stringRange = NSRange(location: 0, length: string.length) 419 | 420 | let matches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") 421 | 422 | DispatchQueue.main.async { [weak self] in 423 | guard let self = self, 424 | let textEditorView = self.textEditorView, 425 | let attachmentAnimatedImage = self.attachmentAnimatedImage, 426 | let attachmentStillImage = self.attachmentStillImage 427 | else { 428 | completion(nil) 429 | return 430 | } 431 | 432 | // TODO: Implement reusable view like table view cell. 433 | for view in self.attachmentViews { 434 | view.removeFromSuperview() 435 | } 436 | 437 | let attributedString = NSMutableAttributedString(attributedString: attributedString) 438 | attributedString.removeAttribute(.suffixedAttachment, range: stringRange) 439 | attributedString.removeAttribute(.underlineStyle, range: stringRange) 440 | attributedString.addAttribute(.foregroundColor, value: UIColor.defaultText, range: stringRange) 441 | 442 | for match in matches { 443 | if let name = string.substring(with: match, at: 2) { 444 | let attachment: TextAttributes.SuffixedAttachment? 445 | switch name { 446 | case "wowcat": 447 | let imageView = UIImageView() 448 | imageView.image = attachmentAnimatedImage 449 | imageView.startAnimating() 450 | 451 | textEditorView.textContentView.addSubview(imageView) 452 | self.attachmentViews.append(imageView) 453 | 454 | let layoutInTextContainer = { [weak textEditorView] (view: UIView, frame: CGRect) in 455 | // `textEditorView` retains `textStorage`, which retains this block as a part of attributes. 456 | guard let textEditorView = textEditorView else { 457 | return 458 | } 459 | let insets = textEditorView.textContentInsets 460 | view.frame = frame.offsetBy(dx: insets.left, dy: insets.top) 461 | } 462 | attachment = .init(size: CGSize(width: 20.0, height: 20.0), 463 | attachment: .view(view: imageView, layoutInTextContainer: layoutInTextContainer)) 464 | case "cat": 465 | attachment = .init(size: CGSize(width: 20.0, height: 20.0), 466 | attachment: .image(attachmentStillImage)) 467 | default: 468 | attachment = nil 469 | } 470 | 471 | if let attachment = attachment { 472 | let index = match.range.upperBound - 1 473 | attributedString.addAttribute(.suffixedAttachment, 474 | value: attachment, 475 | range: NSRange(location: index, length: 1)) 476 | } 477 | } 478 | 479 | var attributes = [NSAttributedString.Key: Any]() 480 | attributes[.foregroundColor] = UIColor.systemBlue 481 | // See `traitCollectionDidChange(_:)` 482 | if #available(iOS 13.0, *) { 483 | switch self.traitCollection.accessibilityContrast { 484 | case .high: 485 | attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue 486 | default: 487 | break 488 | } 489 | } 490 | attributedString.addAttributes(attributes, range: match.range) 491 | } 492 | completion(attributedString) 493 | } 494 | } 495 | } 496 | } 497 | 498 | // MARK: - SuggestViewControllerDelegate 499 | 500 | extension SwiftViewController: SuggestViewControllerDelegate { 501 | func suggestViewController(_ viewController: SuggestViewController, didSelectSuggestedString suggestString: String) { 502 | guard let textEditorView = textEditorView else { 503 | return 504 | } 505 | 506 | let text = textEditorView.text 507 | let selectedRange = textEditorView.selectedRange 508 | 509 | let precedingText = text.substring(with: NSRange(location: 0, length: selectedRange.upperBound)) 510 | 511 | if let match = precedingText.firstMatch(pattern: "#[^\\s]*\\z") { 512 | let location = match.range.location 513 | let range = NSRange(location: location, length: (text.length - location)) 514 | if let match = text.firstMatch(pattern: "#[^\\s]* ?", range: range) { 515 | let replacingRange = match.range 516 | do { 517 | let replacingText = "#\(suggestString) " 518 | let selectedRange = NSRange(location: location + replacingText.length, length: 0) 519 | try textEditorView.updateByReplacing(range: replacingRange, with: replacingText, selectedRange: selectedRange) 520 | } catch { 521 | } 522 | } 523 | } 524 | dismissSuggests() 525 | } 526 | } 527 | -------------------------------------------------------------------------------- /Examples/Example/Sources/TextEditorBridgeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextEditorBridgeView.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import TwitterTextEditor 11 | 12 | @objc 13 | protocol TextEditorBridgeViewDelegate: NSObjectProtocol { 14 | @objc 15 | optional func textEditorBridgeView(_ textEditorBridgeView: TextEditorBridgeView, 16 | updateAttributedString attributedString: NSAttributedString, 17 | completion: @escaping (NSAttributedString?) -> Void) 18 | } 19 | 20 | @objc 21 | final class TextEditorBridgeView: UIView { 22 | @objc 23 | weak var delegate: TextEditorBridgeViewDelegate? 24 | 25 | private let textEditorView: TextEditorView 26 | 27 | override init(frame: CGRect) { 28 | textEditorView = TextEditorView() 29 | 30 | super.init(frame: frame) 31 | 32 | var constraints = [NSLayoutConstraint]() 33 | defer { 34 | NSLayoutConstraint.activate(constraints) 35 | } 36 | 37 | textEditorView.textAttributesDelegate = self 38 | addSubview(textEditorView) 39 | 40 | textEditorView.translatesAutoresizingMaskIntoConstraints = false 41 | constraints.append(textEditorView.topAnchor.constraint(equalTo: topAnchor)) 42 | constraints.append(textEditorView.leadingAnchor.constraint(equalTo: leadingAnchor)) 43 | constraints.append(textEditorView.bottomAnchor.constraint(equalTo: bottomAnchor)) 44 | constraints.append(textEditorView.trailingAnchor.constraint(equalTo: trailingAnchor)) 45 | } 46 | 47 | @available(*, unavailable) 48 | required init?(coder: NSCoder) { 49 | fatalError() 50 | } 51 | 52 | // MARK: - Properties 53 | 54 | @objc 55 | var isEditing: Bool { 56 | get { 57 | textEditorView.isEditing 58 | } 59 | set { 60 | textEditorView.isEditing = newValue 61 | } 62 | } 63 | 64 | @objc 65 | var font: UIFont? { 66 | get { 67 | textEditorView.font 68 | } 69 | set { 70 | textEditorView.font = newValue 71 | } 72 | } 73 | 74 | @objc 75 | var text: String { 76 | get { 77 | textEditorView.text 78 | } 79 | set { 80 | textEditorView.text = newValue 81 | } 82 | } 83 | 84 | @objc 85 | var scrollView: UIScrollView { 86 | textEditorView.scrollView 87 | } 88 | } 89 | 90 | // MARK: - TextEditorViewTextAttributesDelegate 91 | 92 | extension TextEditorBridgeView: TextEditorViewTextAttributesDelegate { 93 | func textEditorView(_ textEditorView: TextEditorView, 94 | updateAttributedString attributedString: NSAttributedString, 95 | completion: @escaping (NSAttributedString?) -> Void) 96 | { 97 | if delegate?.textEditorBridgeView?(self, updateAttributedString: attributedString, completion: completion) == nil { 98 | completion(nil) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Examples/Example/Sources/TwitterTextEditorBridgeConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwitterTextEditorBridgeConfiguration.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import TwitterTextEditor 11 | 12 | /** 13 | This is an example of how we can configure Twitter Text Editor from Objective-C. 14 | */ 15 | @objc 16 | final class TwitterTextEditorBridgeConfiguration: NSObject { 17 | let configuration: Configuration 18 | 19 | @objc(sharedConfiguration) 20 | static func shared() -> Self { 21 | Self(configuration: TwitterTextEditor.Configuration.shared) 22 | } 23 | 24 | init(configuration: Configuration) { 25 | self.configuration = configuration 26 | super.init() 27 | } 28 | 29 | @objc 30 | var logger: TwitterTextEditorLogger? { 31 | get { 32 | if let loggerWrapper = configuration.logger as? LoggerWrapper { 33 | return loggerWrapper.logger 34 | } 35 | return nil 36 | } 37 | set { 38 | configuration.logger = newValue.map { LoggerWrapper(logger: $0) } 39 | } 40 | } 41 | 42 | @objc 43 | var tracer: TwitterTextEditorTracer? { 44 | get { 45 | if let tracerWrapper = configuration.tracer as? TracerWrapper { 46 | return tracerWrapper.tracer 47 | } 48 | return nil 49 | } 50 | set { 51 | configuration.tracer = newValue.map { TracerWrapper(tracer: $0) } 52 | } 53 | } 54 | 55 | @objc 56 | var isDebugLayoutManagerDrawGlyphsEnabled: Bool { 57 | get { 58 | configuration.isDebugLayoutManagerDrawGlyphsEnabled 59 | } 60 | set { 61 | configuration.isDebugLayoutManagerDrawGlyphsEnabled = newValue 62 | } 63 | } 64 | } 65 | 66 | // MARK: - Logger 67 | 68 | @objc 69 | enum TwitterTextEditorLogType: Int { 70 | case `default` = 0 71 | case info 72 | case debug 73 | case error 74 | case fault 75 | } 76 | 77 | @objc 78 | protocol TwitterTextEditorLogger { 79 | @objc 80 | func log(type: TwitterTextEditorLogType, 81 | file: String, 82 | line: Int, 83 | function: String, 84 | message: String?) 85 | } 86 | 87 | private extension TwitterTextEditor.LogType { 88 | var logType: TwitterTextEditorLogType { 89 | switch self { 90 | case .default: 91 | return .default 92 | case .info: 93 | return .info 94 | case .debug: 95 | return .debug 96 | case .error: 97 | return .error 98 | case .fault: 99 | return .fault 100 | } 101 | } 102 | } 103 | 104 | private struct LoggerWrapper: TwitterTextEditor.Logger { 105 | @usableFromInline 106 | var logger: TwitterTextEditorLogger 107 | 108 | @inlinable 109 | func log(type: TwitterTextEditor.LogType, 110 | file: StaticString, 111 | line: Int, 112 | function: StaticString, 113 | message: @autoclosure () -> String?) 114 | { 115 | logger.log(type: type.logType, 116 | file: String(describing: file), 117 | line: line, 118 | function: String(describing: function), 119 | message: message()) 120 | } 121 | } 122 | 123 | // MARK: - Tracer 124 | 125 | @objc 126 | protocol TwitterTextEditorSignpost { 127 | func begin() 128 | func end() 129 | func event() 130 | } 131 | 132 | private struct SignpostWrapper: TwitterTextEditor.Signpost { 133 | @usableFromInline 134 | var signpost: TwitterTextEditorSignpost 135 | 136 | @inlinable 137 | func begin() { 138 | signpost.begin() 139 | } 140 | 141 | @inlinable 142 | func end() { 143 | signpost.end() 144 | } 145 | 146 | @inlinable 147 | func event() { 148 | signpost.event() 149 | } 150 | } 151 | 152 | @objc 153 | protocol TwitterTextEditorTracer { 154 | @objc 155 | func signpost(name: String, 156 | file: String, 157 | line: Int, 158 | function: String, 159 | message: String?) -> TwitterTextEditorSignpost 160 | } 161 | 162 | private struct TracerWrapper: TwitterTextEditor.Tracer { 163 | @usableFromInline 164 | var tracer: TwitterTextEditorTracer 165 | 166 | @inlinable 167 | func signpost(name: StaticString, 168 | file: StaticString, 169 | line: Int, 170 | function: StaticString, 171 | message: @autoclosure () -> String?) -> TwitterTextEditor.Signpost 172 | { 173 | let signpost = tracer.signpost(name: String(describing: name), 174 | file: String(describing: name), 175 | line: line, 176 | function: String(describing: name), 177 | message: message()) 178 | return SignpostWrapper(signpost: signpost) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /Examples/Example/Sources/UIColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIColor { 13 | @objc(defaultTextColor) 14 | static var defaultText: UIColor { 15 | if #available(iOS 13.0, *) { 16 | return .label 17 | } else { 18 | return .black 19 | } 20 | } 21 | 22 | @objc(defaultBackgroundColor) 23 | static var defaultBackground: UIColor { 24 | if #available(iOS 13.0, *) { 25 | return .systemBackground 26 | } else { 27 | return .white 28 | } 29 | } 30 | 31 | @objc(defaultBorderColor) 32 | static var defaultBorder: UIColor { 33 | if #available(iOS 13.0, *) { 34 | return .separator 35 | } else { 36 | return .systemGray 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Examples/Example/Sources/UIImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage.swift 3 | // Example 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIImage { 13 | static func animatedImage(named: NSDataAssetName, duration: TimeInterval) -> UIImage? { 14 | guard let dataAsset = NSDataAsset(name: named), 15 | let source = CGImageSourceCreateWithData(dataAsset.data as CFData, nil) else { 16 | return nil 17 | } 18 | 19 | let count = CGImageSourceGetCount(source) 20 | 21 | var images = [UIImage]() 22 | for index in 0.. Void 23 | } 24 | 25 | private var sections = [Section]() 26 | 27 | init() { 28 | super.init(nibName: nil, bundle: nil) 29 | 30 | title = "TwitterTextEditor" 31 | 32 | var exampleItems = [Item]() 33 | exampleItems.append(Item(title: "Swift example", description: "Basic example of TwitterTextKit usage") { (viewController) in 34 | let swiftViewController = SwiftViewController() 35 | swiftViewController.delegate = viewController 36 | // See `Settings.bundle`. 37 | swiftViewController.useCustomDropInteraction = UserDefaults.standard.bool(forKey: "use_custom_drop_interaction") 38 | 39 | let navigationController = UINavigationController(rootViewController: swiftViewController) 40 | viewController.present(navigationController, animated: true, completion: nil) 41 | }) 42 | exampleItems.append(Item(title: "Objective-C example", description: "Objective-C API usage") { (viewController) in 43 | let objcViewController = ObjcViewController() 44 | objcViewController.delegate = viewController 45 | 46 | let navigationController = UINavigationController(rootViewController: objcViewController) 47 | viewController.present(navigationController, animated: true, completion: nil) 48 | }) 49 | sections.append(Section(title: "Examples", description: "Try examples in each language implementation.", items: exampleItems)) 50 | } 51 | 52 | @available(*, unavailable) 53 | required init?(coder: NSCoder) { 54 | fatalError() 55 | } 56 | 57 | // MARK: - UIViewController 58 | 59 | private static let itemCellIdentifier = "itemCellIdentifier" 60 | 61 | override func viewDidLoad() { 62 | super.viewDidLoad() 63 | 64 | let tableView = UITableView(frame: view.bounds, style: .grouped) 65 | tableView.autoresizingMask = [.flexibleHeight, .flexibleWidth] 66 | tableView.dataSource = self 67 | tableView.delegate = self 68 | view.addSubview(tableView) 69 | } 70 | } 71 | 72 | // MARK: - SwiftViewControllerDelegate 73 | 74 | extension ViewController: SwiftViewControllerDelegate { 75 | func swiftViewControllerDidTapDone(_ swiftViewController: SwiftViewController) { 76 | dismiss(animated: true, completion: nil) 77 | } 78 | } 79 | 80 | // MARK: - ObjcViewControllerDelegate 81 | 82 | extension ViewController: ObjcViewControllerDelegate { 83 | func objcViewControllerDidTapDone(_ objcViewController: ObjcViewController) { 84 | dismiss(animated: true, completion: nil) 85 | } 86 | } 87 | 88 | // MARK: - UITableViewDataSource 89 | 90 | extension ViewController: UITableViewDataSource { 91 | func numberOfSections(in tableView: UITableView) -> Int { 92 | sections.count 93 | } 94 | 95 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 96 | sections[section].title 97 | } 98 | 99 | func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { 100 | sections[section].description 101 | } 102 | 103 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 104 | sections[section].items.count 105 | } 106 | 107 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 108 | let cell = tableView.dequeueReusableCell(withIdentifier: ViewController.itemCellIdentifier) ?? 109 | UITableViewCell(style: .subtitle, reuseIdentifier: ViewController.itemCellIdentifier) 110 | let item = sections[indexPath.section].items[indexPath.row] 111 | cell.textLabel?.text = item.title 112 | cell.detailTextLabel?.text = item.description 113 | return cell 114 | } 115 | } 116 | 117 | // MARK: - UITableViewDelegate 118 | 119 | extension ViewController: UITableViewDelegate { 120 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 121 | tableView.deselectRow(at: indexPath, animated: true) 122 | 123 | let item = sections[indexPath.section].items[indexPath.row] 124 | item.action(self) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Examples/README.md: -------------------------------------------------------------------------------- 1 | # Twitter Text Editor Example 2 | 3 | This is an example project to show how to use Twitter Text Editor in a real application. 4 | The example application demonstrates the following features. 5 | 6 | - How to implement syntax highlighting. 7 | - How to implement text filtering. 8 | - How to implement simple autocompletion. 9 | - Supporting custom pasting and dropping items. 10 | 11 | 12 | ## Usage 13 | 14 | Select `Example` scheme with preferred iOS simulator and simply run it. 15 | 16 | ### Running example application on the device 17 | 18 | You need to manually select your own “Provisioning Profile” that matches to this example project 19 | in “Signing and Capabilities” tab for Example target. 20 | 21 | 22 | ## Structure 23 | 24 | ### `Example/Sources` 25 | 26 | This group contains both Swift and Objective-C example source code. 27 | 28 | - `Objective-C Examples` 29 | 30 | This group contains example Objective-C also Swift source code to show how to use Twitter Text Editor API from Objective-C project. 31 | It contains API bridge examples as well. 32 | 33 | - `Swift Examples` 34 | 35 | This group contains example Swift source code to show how to use Twitter Text Editor in your view controller. 36 | 37 | ### `Packages/TwitterTextEditor` 38 | 39 | This is referencing local Twitter Text Editor package itself. 40 | It is useful for actual Twitter Text Editor development. 41 | 42 | It is following standard Swift Package Manager structure. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Twitter, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | NAME = TwitterTextEditor 5 | 6 | BUILD_SCHEME = $(NAME)-Package 7 | BUILD_DESTINATION = generic/platform=iOS 8 | BUILD_CONFIGURATION = Debug 9 | BUILD_DERIVED_DATA_PATH = .build/derived_data 10 | 11 | # Use `xcodebuild -showdestinations -scheme ...` for the destinations. 12 | # See also 13 | # for commonly available destinations. 14 | TEST_DESTINATION = platform=iOS Simulator,name=iPhone 14 15 | 16 | # This path depends on `BUILD_DESTINATION`. 17 | DOCBUILD_DOCARCHIVE_PATH = $(BUILD_DERIVED_DATA_PATH)/Build/Products/$(BUILD_CONFIGURATION)-iphoneos/$(NAME).doccarchive 18 | 19 | GITHUB_REPOSITORY_NAME = $(NAME) 20 | GITHUB_PAGES_PATH ?= .gh-pages 21 | 22 | DOCUMENTATION_SERVER_ROOT_PATH = .build/documentation 23 | DOCUMENTATION_SERVER_PORT = 3000 24 | # This is simulating how GitHub pages URL is represented, which is `https://$(USERNAME).github.io/$(REPOSITORY_NAME)/`. 25 | DOCUMENTATION_OUTPUT_PATH = $(DOCUMENTATION_SERVER_ROOT_PATH)/$(GITHUB_REPOSITORY_NAME) 26 | DOCUMENTATION_ROOT_TARGET_NAME = twittertexteditor 27 | 28 | XCODEBUILD = xcodebuild 29 | DOCC = xcrun docc 30 | PYTHON3 = xcrun python3 31 | SWIFT = swift 32 | SWIFTLINT = swiftlint 33 | 34 | .PHONY: all 35 | all: fix test 36 | 37 | .PHONY: clean 38 | clean: 39 | git clean -dfX 40 | 41 | .PHONY: fix 42 | fix: 43 | $(SWIFTLINT) --fix 44 | 45 | .PHONY: lint 46 | lint: 47 | $(SWIFTLINT) --strict 48 | 49 | .PHONY: build 50 | build: 51 | $(XCODEBUILD) \ 52 | -scheme "$(BUILD_SCHEME)" \ 53 | -destination "$(BUILD_DESTINATION)" \ 54 | -configuration "$(BUILD_CONFIGURATION)" \ 55 | -derivedDataPath "$(BUILD_DERIVED_DATA_PATH)" \ 56 | build 57 | 58 | .PHONY: test 59 | test: 60 | $(XCODEBUILD) \ 61 | -scheme "$(BUILD_SCHEME)" \ 62 | -destination "$(TEST_DESTINATION)" \ 63 | -configuration "$(BUILD_CONFIGURATION)" \ 64 | -derivedDataPath "$(BUILD_DERIVED_DATA_PATH)" \ 65 | test 66 | 67 | .PHONY: docbuild 68 | docbuild: 69 | $(XCODEBUILD) \ 70 | -scheme "$(BUILD_SCHEME)" \ 71 | -destination "$(BUILD_DESTINATION)" \ 72 | -configuration "$(BUILD_CONFIGURATION)" \ 73 | -derivedDataPath "$(BUILD_DERIVED_DATA_PATH)" \ 74 | docbuild 75 | 76 | .PHONY: doc 77 | doc: docbuild 78 | mkdir -p "$(DOCUMENTATION_OUTPUT_PATH)" 79 | $(DOCC) process-archive transform-for-static-hosting "$(DOCBUILD_DOCARCHIVE_PATH)" \ 80 | --output-path "$(DOCUMENTATION_OUTPUT_PATH)" \ 81 | --hosting-base-path "/$(GITHUB_REPOSITORY_NAME)" 82 | 83 | .PHONY: doc-server 84 | doc-server: doc 85 | @echo "Documentation is available at " 86 | $(PYTHON3) -m http.server --directory "$(DOCUMENTATION_SERVER_ROOT_PATH)" $(DOCUMENTATION_SERVER_PORT) 87 | 88 | .PHONY: ghpages 89 | ghpages: doc 90 | mkdir -p "$(GITHUB_PAGES_PATH)" 91 | rsync -av8 --exclude .git --delete "$(DOCUMENTATION_OUTPUT_PATH)"/ "$(GITHUB_PAGES_PATH)"/ 92 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | watchers: 2 | - twitter-text-editor-ios:phab 3 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "TwitterTextEditor", 8 | platforms: [ 9 | .iOS(.v11) 10 | ], 11 | products: [ 12 | .library( 13 | name: "TwitterTextEditor-Auto", 14 | targets: [ 15 | "TwitterTextEditor" 16 | ] 17 | ), 18 | .library( 19 | name: "TwitterTextEditor", 20 | type: .dynamic, 21 | targets: [ 22 | "TwitterTextEditor" 23 | ] 24 | ) 25 | ], 26 | targets: [ 27 | .target( 28 | name: "TwitterTextEditor", 29 | dependencies: [] 30 | ), 31 | .testTarget( 32 | name: "TwitterTextEditorTests", 33 | dependencies: [ 34 | "TwitterTextEditor" 35 | ] 36 | ) 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter Text Editor 2 | 3 | A standalone, flexible API that provides a full featured rich text editor for iOS applications. 4 | 5 | ![Twitter Text Editor](Sources/TwitterTextEditor/Documentation.docc/Resources/TwitterTextEditor.png) 6 | 7 | It supports safe text modification, attribute annotations such as syntax highlighting, pasting or drag and drop handling. 8 | 9 | This provides a robust text attribute update logic, extended text editing events, and safe text input event handling in easy delegate based APIs. 10 | TwitterTextEditor supports recent versions of iOS. 11 | 12 | 13 | ## Requirements 14 | 15 | Twitter Text Editor requires macOS Catalina 10.15 or later and Xcode 11.0 and later for the development. 16 | At this moment, Twitter Text Editor supports iOS 11.0 and later also macCatalyst 13.0 and later. 17 | 18 | 19 | ## Usage 20 | 21 | Using Twitter Text Editor is straightforward if you're familiar with iOS development. See 22 | also [Examples](Examples/) for actual usage, that contains Swift and Objective-C source code 23 | to show how to use Twitter Text Editor. See [`Examples/README.md`](Examples/README.md) as well. 24 | 25 | ### Add Twitter Text Editor framework to your project 26 | 27 | Add the following lines to your `Package.swift` or use Xcode “Add Package Dependency…” menu. 28 | 29 | ```swift 30 | // In your `Package.swift` 31 | 32 | dependencies: [ 33 | .package(name: "TwitterTextEditor", url: "https://github.com/twitter/TwitterTextEditor", ...), 34 | ... 35 | ], 36 | targets: [ 37 | .target( 38 | name: ..., 39 | dependencies: [ 40 | .product(name: "TwitterTextEditor", package: "TwitterTextEditor"), 41 | ... 42 | ] 43 | ), 44 | ... 45 | ] 46 | ``` 47 | 48 | ### Use with other dependency management tools 49 | 50 | In case your project is not using Swift Package Manager, 51 | you can use Twitter Text Editor with other dependency management tools. 52 | 53 | #### CocoaPods 54 | 55 | To use Twitter Text Editor with [CocoaPods](https://cocoapods.org/), add next `TwitterTextEditor.podspec` in your project. 56 | 57 | ```ruby 58 | Pod::Spec.new do |spec| 59 | spec.name = "TwitterTextEditor" 60 | spec.version = "1.0.0" # Find the the version from the Git tags 61 | spec.authors = "" 62 | spec.summary = "TwitterTextEditor" 63 | spec.homepage = "https://github.com/twitter/TwitterTextEditor" 64 | spec.platform = :ios, "11.0" 65 | spec.source = { 66 | :git => "https://github.com/twitter/TwitterTextEditor.git", :tag => "#{spec.version}" 67 | } 68 | spec.source_files = "Sources/TwitterTextEditor/*.swift" 69 | end 70 | ``` 71 | 72 | Then, update `Podfile` in your project. 73 | 74 | ```ruby 75 | pod 'TwitterTextEditor', :podspec => 'path/to/TwitterTextEditor.podspec' 76 | ``` 77 | 78 | #### Carthage 79 | 80 | To use Twitter Text Editor with [Carthage](https://github.com/Carthage/Carthage), update `Cartfile` in your project. 81 | 82 | ``` 83 | github "twitter/TwitterTextEditor" 84 | ``` 85 | 86 | Then, run following commands. This will create `Carthage/Build/iOS/TwitterTextEditor.framework`. 87 | 88 | ``` 89 | $ carthage update 90 | $ (cd Carthage/Checkouts/TwitterTextEditor && swift package generate-xcodeproj) 91 | $ carthage build --platform iOS 92 | ``` 93 | 94 | Follow [the instructions](https://github.com/Carthage/Carthage#if-youre-building-for-ios-tvos-or-watchos) 95 | to add the framework and Run Script phase to your project. 96 | 97 | ### Documentation 98 | 99 | See [documentation](https://twitter.github.io/TwitterTextEditor/documentation/twittertexteditor). 100 | 101 | 102 | ## Use Twitter Text Editor in your project 103 | 104 | Twitter Text Editor provides a single view, `TextEditorView`, that has a similar API 105 | to `UITextView` and provides the most of features as a property or a delegate callback. 106 | 107 | Add it to your project as like the other views, and setup using each property or implement delegate callbacks. 108 | 109 | ```swift 110 | // In your view controller 111 | 112 | import TwitterTextEditor 113 | 114 | final class MyViewController: UIViewController { 115 | // ... 116 | 117 | override func viewDidLoad() { 118 | super.viewDidLoad() 119 | // ... 120 | let textEditorView = TextEditorView() 121 | textEditorView.text = "Meow" 122 | textEditorView.textAttributesDelegate = self 123 | // ... 124 | } 125 | 126 | // ... 127 | } 128 | 129 | extension MyViewController: TextEditorViewTextAttributesDelegate { 130 | func textEditorView(_ textEditorView: TextEditorView, 131 | updateAttributedString attributedString: NSAttributedString, 132 | completion: @escaping (NSAttributedString?) -> Void) 133 | { 134 | // ... 135 | } 136 | } 137 | ``` 138 | 139 | 140 | ## Contributing 141 | 142 | See [CONTRIBUTING.md](CONTRIBUTING.md) for the details. 143 | 144 | 145 | ## Security issues 146 | 147 | Please report sensitive security issues via [Twitter’s bug-bounty program](https://hackerone.com/twitter) rather than GitHub. 148 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Configuration class for logging, tracing, and debugging options. 13 | */ 14 | public final class Configuration { 15 | /** 16 | Shared configuration instance. 17 | Use this instance to configure this module. 18 | */ 19 | public static let shared = Configuration() 20 | 21 | /** 22 | Logger for TwitterTextEditor module. 23 | Default to `nil`. 24 | */ 25 | public var logger: Logger? 26 | 27 | /** 28 | Tracer for TwitterTextEditor module. 29 | Default to `nil`. 30 | */ 31 | public var tracer: Tracer? 32 | 33 | /** 34 | Use short description for logging `NSAttributedString`. 35 | Default to `true`. 36 | */ 37 | public var isAttributedStringShortDescriptionForLoggingEnabled: Bool = true 38 | /** 39 | A set of attribute names described in short description for `NSAttributedString`. 40 | Default to `nil`. 41 | */ 42 | public var attributeNamesDescribedForAttributedStringShortDescription: Set? 43 | 44 | /** 45 | Enable debugging for `drawGlyphs(forGlyphRange:at:)`. 46 | Default to `false`. 47 | */ 48 | public var isDebugLayoutManagerDrawGlyphsEnabled: Bool = false 49 | } 50 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/Documentation.docc/Resources/TwitterTextEditor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twitter/TwitterTextEditor/39256a958ade4a682e43f21f22140a5b3bbe280a/Sources/TwitterTextEditor/Documentation.docc/Resources/TwitterTextEditor.png -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/Documentation.docc/TwitterTextEditor.md: -------------------------------------------------------------------------------- 1 | # ``TwitterTextEditor`` 2 | 3 | A standalone, flexible API that provides a full featured rich text editor for iOS applications. 4 | 5 | ## Overview 6 | 7 | ![Twitter Text Editor](TwitterTextEditor.png) 8 | 9 | It supports safe text modification, attribute annotations such as syntax highlighting, pasting or drag and drop handling. 10 | 11 | This provides a robust text attribute update logic, extended text editing events, and safe text input event handling in easy delegate based APIs. 12 | TwitterTextEditor supports recent versions of iOS. 13 | 14 | See [GitHub repository](https://github.com/twitter/TwitterTextEditor/) for the details. 15 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/EditingContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditingContent.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | struct EditingContent: Equatable { 12 | enum InitializeError: Error { 13 | case outOfSelectedRange(validRange: NSRange) 14 | } 15 | 16 | var text: String 17 | var selectedRange: NSRange 18 | 19 | init(text: String, selectedRange: NSRange) throws { 20 | guard text.range.contains(selectedRange) else { 21 | throw InitializeError.outOfSelectedRange(validRange: text.range) 22 | } 23 | 24 | self.text = text 25 | self.selectedRange = selectedRange 26 | } 27 | 28 | // MARK: - 29 | 30 | enum UpdateError: Error { 31 | case outOfReplacingRange(validRange: NSRange) 32 | } 33 | 34 | struct UpdateRequest { 35 | var replacingRange: NSRange? 36 | var replacingText: String? 37 | var selectedRange: NSRange? 38 | 39 | static let null = Self(replacingRange: nil, replacingText: nil, selectedRange: nil) 40 | 41 | static func text(_ text: String, selectedRange: NSRange? = nil) -> Self { 42 | .init(replacingRange: nil, replacingText: text, selectedRange: selectedRange) 43 | } 44 | 45 | static func subtext(range: NSRange, text: String, selectedRange: NSRange? = nil) -> Self { 46 | .init(replacingRange: range, replacingText: text, selectedRange: selectedRange) 47 | } 48 | 49 | static func selectedRange(_ selectedRange: NSRange) -> Self { 50 | .init(replacingRange: nil, replacingText: nil, selectedRange: selectedRange) 51 | } 52 | } 53 | 54 | func update(with request: UpdateRequest) throws -> EditingContent { 55 | let updatedText: String 56 | let updatedSelectedRange: NSRange 57 | 58 | if let replacingText = request.replacingText { 59 | let textRange = text.range 60 | let replacingRange = request.replacingRange ?? textRange 61 | guard textRange.contains(replacingRange) else { 62 | throw UpdateError.outOfReplacingRange(validRange: textRange) 63 | } 64 | 65 | updatedText = text.replacingCharacters(in: replacingRange, with: replacingText) 66 | updatedSelectedRange = request.selectedRange ?? selectedRange.movedByReplacing(range: replacingRange, length: replacingText.length) 67 | } else { 68 | updatedText = text 69 | updatedSelectedRange = request.selectedRange ?? selectedRange 70 | } 71 | 72 | return try EditingContent(text: updatedText, selectedRange: updatedSelectedRange) 73 | } 74 | 75 | // MARK: - 76 | 77 | struct ChangeResult: Equatable { 78 | var isTextChanged: Bool 79 | var isSelectedRangeChanged: Bool 80 | } 81 | 82 | func changeResult(from content: EditingContent) -> ChangeResult? { 83 | switch ((text != content.text), (selectedRange != content.selectedRange)) { 84 | case (false, false): 85 | return nil 86 | case (let isTextChanged, let isSelectedRangeChanged): 87 | return ChangeResult(isTextChanged: isTextChanged, isSelectedRangeChanged: isSelectedRangeChanged) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/LayoutManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutManager.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | final class LayoutManager: NSLayoutManager { 13 | private var glyphsCache: [CacheableGlyphs] = [] 14 | 15 | override init() { 16 | super.init() 17 | super.delegate = self 18 | } 19 | 20 | @available(*, unavailable) 21 | public required init?(coder: NSCoder) { 22 | fatalError() 23 | } 24 | 25 | // MARK: - NSLayoutManager 26 | 27 | /* 28 | UIKit behavior note 29 | 30 | - Confirmed on iOS 13.6 and prior. 31 | 32 | There are UIKit implementations that are replacing `delegate` to do its tasks 33 | which eventually set it back to original value, such as `sizeThatFits:`. 34 | 35 | Because of these behaviors, we should not refuse to modify `delegate` even if 36 | the setter is marked as unavailable and it can break a consistency of this 37 | layout manager implementation. 38 | 39 | - SeeAlso: 40 | - `-[UITextView _performLayoutCalculation:inSize:]` 41 | */ 42 | override var delegate: NSLayoutManagerDelegate? { 43 | get { 44 | super.delegate 45 | } 46 | @available(*, unavailable) 47 | set { 48 | if !(newValue is LayoutManager) { 49 | log(type: .error, "LayoutManager delegate should not be modified to delegate: %@", String(describing: newValue)) 50 | } 51 | super.delegate = newValue 52 | } 53 | } 54 | 55 | override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) { 56 | log(type: .debug, "range: %@, at: %@", NSStringFromRange(glyphsToShow), NSCoder.string(for: origin)) 57 | 58 | super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) 59 | 60 | // See the inline comment for `delegate`. 61 | guard delegate is LayoutManager else { 62 | return 63 | } 64 | guard let textStorage = textStorage else { 65 | return 66 | } 67 | 68 | guard glyphsToShow.length > 0 else { 69 | return 70 | } 71 | 72 | let count = glyphsToShow.length 73 | var properties = [GlyphProperty](repeating: [], count: count) 74 | var characterIndexes = [Int](repeating: 0, count: count) 75 | properties.withUnsafeMutableBufferPointer { props -> Void in 76 | characterIndexes.withUnsafeMutableBufferPointer { charIndexes -> Void in 77 | getGlyphs(in: glyphsToShow, 78 | glyphs: nil, 79 | properties: props.baseAddress, 80 | characterIndexes: charIndexes.baseAddress, 81 | bidiLevels: nil) 82 | } 83 | } 84 | 85 | guard let context = UIGraphicsGetCurrentContext() else { 86 | return 87 | } 88 | context.saveGState() 89 | 90 | if Configuration.shared.isDebugLayoutManagerDrawGlyphsEnabled { 91 | context.setStrokeColor(UIColor.blue.withAlphaComponent(0.5).cgColor) 92 | context.setLineDash(phase: 0, lengths: [2, 2]) 93 | } 94 | 95 | // TODO: Measure performance and consider different approach. 96 | // This scans entire glyph range once. 97 | 98 | let signpostScanGlyphsToShow = signpost(name: "Scan glyphs to show", "length: %d", glyphsToShow.length) 99 | signpostScanGlyphsToShow.begin() 100 | for index in 0.. 135 | var properties: UnsafeBufferPointer 136 | var characterIndexes: UnsafeBufferPointer 137 | 138 | init(glyphs: UnsafePointer, 139 | properties: UnsafePointer, 140 | characterIndexes: UnsafePointer, count: Int) { 141 | self.count = count 142 | 143 | self.glyphs = UnsafeBufferPointer(start: glyphs, count: count) 144 | self.properties = UnsafeBufferPointer(start: properties, count: count) 145 | self.characterIndexes = UnsafeBufferPointer(start: characterIndexes, count: count) 146 | } 147 | } 148 | 149 | private struct MutableGlyphs { 150 | struct Insertion { 151 | var index: Int 152 | 153 | var glyph: CGGlyph 154 | var property: NSLayoutManager.GlyphProperty 155 | var characterIndex: Int 156 | } 157 | 158 | private(set) var glyphs: [CGGlyph] 159 | private(set) var properties: [NSLayoutManager.GlyphProperty] 160 | private(set) var characterIndexes: [Int] 161 | 162 | init(unsafeBufferGlyphs: UnsafeBufferGlyphs) { 163 | glyphs = Array(unsafeBufferGlyphs.glyphs) 164 | properties = Array(unsafeBufferGlyphs.properties) 165 | characterIndexes = Array(unsafeBufferGlyphs.characterIndexes) 166 | } 167 | 168 | mutating func insert(_ insertion: Insertion, offset: Int = 0) { 169 | let index = insertion.index + offset 170 | glyphs.insert(insertion.glyph, at: index) 171 | properties.insert(insertion.property, at: index) 172 | characterIndexes.insert(insertion.characterIndex, at: index) 173 | } 174 | } 175 | 176 | private class CacheableGlyphs { 177 | let glyphs: UnsafeMutableBufferPointer 178 | let properties: UnsafeMutableBufferPointer 179 | let characterIndexes: UnsafeMutableBufferPointer 180 | 181 | init(mutableGlyphs: MutableGlyphs) { 182 | let glyphs = UnsafeMutableBufferPointer.allocate(capacity: mutableGlyphs.glyphs.count) 183 | _ = glyphs.initialize(from: mutableGlyphs.glyphs) 184 | self.glyphs = glyphs 185 | 186 | let properties = UnsafeMutableBufferPointer.allocate(capacity: mutableGlyphs.properties.count) 187 | _ = properties.initialize(from: mutableGlyphs.properties) 188 | self.properties = properties 189 | 190 | let characterIndexes = UnsafeMutableBufferPointer.allocate(capacity: mutableGlyphs.characterIndexes.count) 191 | _ = characterIndexes.initialize(from: mutableGlyphs.characterIndexes) 192 | self.characterIndexes = characterIndexes 193 | } 194 | 195 | deinit { 196 | self.glyphs.deallocate() 197 | self.properties.deallocate() 198 | self.characterIndexes.deallocate() 199 | } 200 | } 201 | 202 | func layoutManager(_ layoutManager: NSLayoutManager, 203 | shouldGenerateGlyphs glyphs: UnsafePointer, 204 | properties props: UnsafePointer, 205 | characterIndexes charIndexes: UnsafePointer, 206 | font aFont: UIFont, 207 | forGlyphRange glyphRange: NSRange) -> Int 208 | { 209 | guard let textStorage = layoutManager.textStorage else { 210 | return 0 211 | } 212 | 213 | let unsafeBufferGlyphs = UnsafeBufferGlyphs(glyphs: glyphs, 214 | properties: props, 215 | characterIndexes: charIndexes, 216 | count: glyphRange.length) 217 | 218 | var sortedInsertions = [MutableGlyphs.Insertion]() 219 | for index in 0.. Void in 287 | characterIndexes.withUnsafeMutableBufferPointer { charIndexes -> Void in 288 | layoutManager.getGlyphs(in: glyphsToShow, 289 | glyphs: nil, 290 | properties: props.baseAddress, 291 | characterIndexes: charIndexes.baseAddress, 292 | bidiLevels: nil) 293 | } 294 | } 295 | 296 | // TODO: Measure performance and consider different approach. 297 | // This scans entire glyph range once. 298 | 299 | let signpostScanGlyphsToShow = signpost(name: "Scan glyphs to show", "length: %d", glyphsToShow.length) 300 | signpostScanGlyphsToShow.begin() 301 | for index in 0.. NSLayoutManager.ControlCharacterAction 326 | { 327 | guard let textStorage = layoutManager.textStorage else { 328 | return action 329 | } 330 | 331 | let attributes = textStorage.attributes(at: charIndex, effectiveRange: nil) 332 | guard attributes[.suffixedAttachment] is TextAttributes.SuffixedAttachment else { 333 | return action 334 | } 335 | 336 | // `.whitespace` may not be set always by `NSTypesetter`. 337 | // This is only for control glyphs inserted by `layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)`. 338 | return .whitespace 339 | } 340 | 341 | func layoutManager(_ layoutManager: NSLayoutManager, 342 | boundingBoxForControlGlyphAt glyphIndex: Int, 343 | for textContainer: NSTextContainer, 344 | proposedLineFragment proposedRect: CGRect, 345 | glyphPosition: CGPoint, 346 | characterIndex charIndex: Int) -> CGRect 347 | { 348 | guard let textStorage = layoutManager.textStorage else { 349 | return .zero 350 | } 351 | 352 | let attributes = textStorage.attributes(at: charIndex, effectiveRange: nil) 353 | guard let suffixedAttachment = attributes[.suffixedAttachment] as? TextAttributes.SuffixedAttachment else { 354 | // Should't reach here. 355 | // See `layoutManager(_:shouldUse:forControlCharacterAt:)`. 356 | assertionFailure("Glyphs that have .suffixedAttachment shouldn't be a control glyphs") 357 | return .zero 358 | } 359 | 360 | return CGRect(origin: glyphPosition, size: suffixedAttachment.size) 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Logging levels supported by `Logger`. 13 | 14 | Compatible with `OSLogType`. 15 | 16 | - SeeAlso: 17 | - `OSLogType` 18 | */ 19 | public enum LogType { 20 | /** 21 | Default messages. 22 | */ 23 | case `default` 24 | /** 25 | Informational messages. 26 | */ 27 | case info 28 | /** 29 | Debug messages. 30 | */ 31 | case debug 32 | /** 33 | Error condition messages. 34 | */ 35 | case error 36 | /** 37 | Unexpected condition messages. 38 | */ 39 | case fault 40 | } 41 | 42 | /** 43 | Logging interface used in this module. 44 | */ 45 | public protocol Logger { 46 | /** 47 | Method for logging. 48 | 49 | - Parameters: 50 | - type: Type of log. 51 | - file: Source file path to the logging position. 52 | - line: Line to the logging position in the `file`. 53 | - function: Function name of the logging position. 54 | - message: Log message. 55 | */ 56 | func log(type: LogType, 57 | file: StaticString, 58 | line: Int, 59 | function: StaticString, 60 | message: @autoclosure () -> String?) 61 | } 62 | 63 | /** 64 | Default logging function. 65 | 66 | - Parameters: 67 | - type: Type of log. 68 | - file: Source file path to the logging position. Default to `#file`. 69 | - line: Line to the logging position in the `file`. Default to `#line` 70 | - function: Function name of the logging position. Default to `#function`. 71 | - message: Log message. Default to `nil`. 72 | */ 73 | func log(type: LogType, 74 | file: StaticString = #file, 75 | line: Int = #line, 76 | function: StaticString = #function, 77 | _ message: @autoclosure () -> String? = nil) 78 | { 79 | guard let logger = Configuration.shared.logger else { 80 | return 81 | } 82 | 83 | logger.log(type: type, 84 | file: file, 85 | line: line, 86 | function: function, 87 | message: message()) 88 | } 89 | 90 | /** 91 | Logging function with format. 92 | 93 | - Parameters: 94 | - type: Type of log. 95 | - file: Source file path to the logging position. Default to `#file`. 96 | - line: Line to the logging position in the `file`. Default to `#line` 97 | - function: Function name of the logging position. Default to `#function`. 98 | - format: Log message format. 99 | - arguments: Arguments for `format`. 100 | */ 101 | func log(type: LogType, 102 | file: StaticString = #file, 103 | line: Int = #line, 104 | function: StaticString = #function, 105 | _ format: String? = nil, 106 | _ arguments: CVarArg...) 107 | { 108 | log(type: type, 109 | file: file, 110 | line: line, 111 | function: function, 112 | format.map { String(format: $0, arguments: arguments) }) 113 | } 114 | 115 | /** 116 | A wrapper that provides custom descriptions to an arbitrary item for logging. 117 | 118 | This is intentionally `NSObject` to conform `CVarArg`. 119 | */ 120 | final class CustomDescribing: NSObject { 121 | let item: T 122 | let describe: (T) -> String 123 | 124 | init(_ item: T, describe: @escaping (T) -> String) { 125 | self.item = item 126 | self.describe = describe 127 | } 128 | 129 | override var description: String { 130 | describe(item) 131 | } 132 | 133 | override var debugDescription: String { 134 | describe(item) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/NSAttributedString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | private extension Dictionary where Key == NSAttributedString.Key { 12 | var shortDescription: String { 13 | let attributeDescriptions: [String] 14 | if let attributeNames = Configuration.shared.attributeNamesDescribedForAttributedStringShortDescription { 15 | attributeDescriptions = map { attribute in 16 | attributeNames.contains(attribute.key) ? "\(attribute.key.rawValue): \(attribute.value)" : attribute.key.rawValue 17 | } 18 | } else { 19 | attributeDescriptions = keys.map { key in 20 | key.rawValue 21 | } 22 | } 23 | return "{\(attributeDescriptions.joined(separator: ", "))}" 24 | } 25 | } 26 | 27 | extension NSAttributedString { 28 | private var shortDescription: String { 29 | guard length > 0 else { 30 | return "" 31 | } 32 | 33 | // Mostly same implementation as original `description` 34 | var description = String() 35 | var index = 0 36 | var effectiveRange: NSRange = .zero 37 | repeat { 38 | let attributes = self.attributes(at: index, effectiveRange: &effectiveRange) 39 | description.append(string.substring(with: effectiveRange)) 40 | description.append(attributes.shortDescription) 41 | index = effectiveRange.upperBound 42 | } while index < length 43 | 44 | return description 45 | } 46 | 47 | var loggingDescription: CustomStringConvertible & CVarArg { 48 | guard Configuration.shared.isAttributedStringShortDescriptionForLoggingEnabled else { 49 | return self 50 | } 51 | 52 | return CustomDescribing(self) { attributedString in 53 | attributedString.shortDescription 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/NSRange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSRange.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSRange { 12 | static var zero: NSRange = NSRange(location: 0, length: 0) 13 | 14 | static var null: NSRange = NSRange(location: NSNotFound, length: 0) 15 | 16 | /** 17 | Reasonably move range for selection in text by replacing the range with length. 18 | See unit tests for the actual behavior. 19 | 20 | - Parameters: 21 | - range: a range to be replaced. 22 | - length: a length that is replacing the `range`. 23 | 24 | - Returns: 25 | - Moved range for selection. 26 | */ 27 | public func movedByReplacing(range: NSRange, length: Int) -> NSRange { 28 | // Intentionally explicitly using `self` in this function implementation to reduce confusion. 29 | 30 | let replacingRange = NSRange(location: range.location, length: length) 31 | let changeInLength = replacingRange.length - range.length 32 | 33 | if range.upperBound <= self.lowerBound { 34 | return NSRange(location: self.location + changeInLength, length: self.length) 35 | } 36 | 37 | if self.upperBound <= range.lowerBound { 38 | return self 39 | } 40 | 41 | let lowerBound = min(self.lowerBound, range.lowerBound) 42 | let upperBound = max(self.upperBound, range.upperBound) 43 | 44 | if self.length == 0 { 45 | let middleBound = (upperBound - lowerBound) / 2 + lowerBound 46 | if self.location < middleBound { 47 | return NSRange(location: lowerBound, length: self.length) 48 | } else { 49 | return NSRange(location: upperBound + changeInLength, length: self.length) 50 | } 51 | } 52 | 53 | return NSRange(location: lowerBound, length: upperBound - lowerBound + changeInLength) 54 | } 55 | 56 | @inlinable 57 | func contains(_ range: NSRange) -> Bool { 58 | lowerBound <= range.lowerBound && range.upperBound <= upperBound 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/NotificationCenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationCenter.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | final class NotificationObserverToken { 12 | private weak var notificationCenter: NotificationCenter? 13 | private var token: NSObjectProtocol? 14 | 15 | fileprivate init(notificationCenter: NotificationCenter, token: NSObjectProtocol) { 16 | self.notificationCenter = notificationCenter 17 | self.token = token 18 | } 19 | 20 | func remove() { 21 | guard let token = token else { 22 | return 23 | } 24 | notificationCenter?.removeObserver(token) 25 | self.token = nil 26 | } 27 | 28 | deinit { 29 | remove() 30 | } 31 | } 32 | 33 | extension NotificationCenter { 34 | func addObserver(forName name: NSNotification.Name?, 35 | object: Any?, 36 | queue: OperationQueue?, 37 | using block: @escaping (Notification) -> Void) -> NotificationObserverToken 38 | { 39 | // Notification center strongly holds this return value until you remove the observer registration. 40 | let token: NSObjectProtocol = addObserver(forName: name, object: object, queue: queue, using: block) 41 | 42 | return NotificationObserverToken(notificationCenter: self, token: token) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/Scheduler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Scheduler.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | A generic scheduling error. 13 | 14 | - SeeAlso: 15 | - `ContentFilterScheduler` 16 | */ 17 | enum SchedulerError: Error { 18 | /** 19 | The schedule is cancelled because it scheduled a new one. 20 | */ 21 | case cancelled 22 | /** 23 | The schedule is executed but the output is obsoleted because the new one is scheduled. 24 | */ 25 | case notLatest 26 | } 27 | 28 | /** 29 | A scheduler that execute a block once each call on the main run loop, or 30 | execute immediately at the specific timing. 31 | 32 | Useful to debounce user interface events delegate callbacks. 33 | See `__CFRunLoopRun(...)` about the execution timing. 34 | 35 | - SeeAlso: 36 | - `__CFRunLoopRun(...)` 37 | */ 38 | final class DebounceScheduler { 39 | typealias Block = () -> Void 40 | 41 | private let block: Block 42 | 43 | /** 44 | Initialize with a block. 45 | 46 | - Parameters: 47 | - block: a block called from each call on the main run loop if it's scheduled. 48 | */ 49 | init(_ block: @escaping Block) { 50 | self.block = block 51 | } 52 | 53 | private var isScheduled: Bool = false 54 | 55 | /** 56 | Schedule calling the block. 57 | */ 58 | func schedule() { 59 | guard !isScheduled else { 60 | log(type: .debug, "Already scheduled.") 61 | return 62 | } 63 | isScheduled = true 64 | 65 | RunLoop.main.perform { 66 | guard self.isScheduled else { 67 | log(type: .debug, "Already performed.") 68 | return 69 | } 70 | self.perform() 71 | } 72 | } 73 | 74 | /** 75 | Immediately call the block. 76 | */ 77 | func perform() { 78 | isScheduled = false 79 | block() 80 | } 81 | } 82 | 83 | /** 84 | A scheduler that executes an asynchronous filter once each call on the main run loop 85 | and caches the immediate previous call also ignores any previous results of the asynchronous filter. 86 | 87 | Useful to execute idempotent filter repeatedly to the input value and eventually get the latest output. 88 | See `__CFRunLoopRun(...)` about the execution timing. 89 | 90 | - SeeAlso: 91 | - `__CFRunLoopRun(...)` 92 | */ 93 | final class ContentFilterScheduler { 94 | typealias Completion = (Result) -> Void 95 | typealias Filter = (Input, @escaping Completion) -> Void 96 | 97 | private let filter: Filter 98 | 99 | /** 100 | Initialize with a filter block. 101 | 102 | - Parameters: 103 | - filter: a block to asynchronously filter `Input` and call `Completion` with `Result`. 104 | It's callers responsibility to call `Completion` or scheduled filtering will never end. 105 | */ 106 | init(filter: @escaping Filter) { 107 | self.filter = filter 108 | } 109 | 110 | private struct Schedule { 111 | var input: Input 112 | var completion: Completion 113 | } 114 | 115 | private struct Cache { 116 | var key: Input 117 | var value: Output 118 | } 119 | 120 | private var schedule: Schedule? 121 | private var cache: Cache? 122 | private var latestToken: NSObject? 123 | 124 | /** 125 | Schedule a filtering. 126 | 127 | - Parameters: 128 | - input: An `Input` to be filtered. 129 | - completion: A block that is called with `Result` when the asynchronous filtering. 130 | `Error` can be one of `SchedulerError` if the schedule is cancelled or there is newer schedule. 131 | 132 | - SeeAlso: 133 | - `SchedulerError` 134 | */ 135 | func schedule(_ input: Input, completion: @escaping Completion) { 136 | let previousSchedule = schedule 137 | schedule = Schedule(input: input, completion: completion) 138 | 139 | // Debounce 140 | if let previousSchedule = previousSchedule { 141 | log(type: .debug, "Cancelled") 142 | previousSchedule.completion(.failure(SchedulerError.cancelled)) 143 | return 144 | } 145 | 146 | RunLoop.main.perform { 147 | guard let schedule = self.schedule else { 148 | assertionFailure() 149 | return 150 | } 151 | self.schedule = nil 152 | 153 | // Cache 154 | if let cache = self.cache, cache.key == schedule.input { 155 | log(type: .debug, "Use cache") 156 | schedule.completion(.success(cache.value)) 157 | return 158 | } 159 | 160 | // Latest 161 | let token = NSObject() 162 | self.latestToken = token 163 | 164 | self.filter(schedule.input) { [weak self] result in 165 | guard let self = self else { 166 | return 167 | } 168 | 169 | guard let latestToken = self.latestToken, latestToken == token else { 170 | log(type: .debug, "Not latest") 171 | completion(.failure(SchedulerError.notLatest)) 172 | return 173 | } 174 | 175 | if case let .success(output) = result { 176 | self.cache = Cache(key: schedule.input, value: output) 177 | } 178 | 179 | schedule.completion(result) 180 | } 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/Sequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | A response of each `body` called asynchronously. 13 | 14 | - SeeAlso: 15 | - `Sequence.forEach(queue:completion:_)` 16 | */ 17 | enum SequenceForEachNextAction { 18 | /** 19 | Continue the enumeration. 20 | */ 21 | case `continue` 22 | /** 23 | End the enumeration and call `completion` if it's there. 24 | */ 25 | case `break` 26 | } 27 | 28 | extension Sequence { 29 | typealias Next = (SequenceForEachNextAction) -> Void 30 | 31 | /** 32 | Enumerate elements one by one asynchronously. 33 | 34 | - Parameters: 35 | - queue: A dispatch queue to enumerate each element. It _MUST BE_ a serial queue or behavior is undefined. 36 | iteration is always executed on this queue. It's caller's responsibility to not modify the sequence 37 | simultaneously while the enumeration or the behavior is undefined. 38 | - completion: An optional block that is called at the end of enumeration. 39 | - body: A block called with each element and a `Next` block. 40 | It's caller's responsibility to call given `Next` block eventually with one of `SequenceForEachNextAction`, 41 | either `.continue` or `.break`. 42 | Otherwise, the enumeration will never end, and the `completion` block will never be called. 43 | */ 44 | func forEach(queue: DispatchQueue, 45 | completion: (() -> Void)? = nil, 46 | _ body: @escaping (Element, @escaping Next) -> Void) 47 | { 48 | var iterator = makeIterator() 49 | var next: Next! 50 | let final = { 51 | next = nil 52 | completion?() 53 | } 54 | next = { action in 55 | queue.async { 56 | switch action { 57 | case .continue: 58 | if let element = iterator.next() { 59 | body(element, next) 60 | } else { 61 | final() 62 | } 63 | case .break: 64 | final() 65 | } 66 | } 67 | } 68 | next(.continue) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | @inlinable 13 | var length: Int { 14 | (self as NSString).length 15 | } 16 | 17 | @inlinable 18 | var range: NSRange { 19 | NSRange(location: 0, length: length) 20 | } 21 | 22 | @inlinable 23 | func replacingCharacters(in range: NSRange, with replacement: String) -> String { 24 | (self as NSString).replacingCharacters(in: range, with: replacement) 25 | } 26 | 27 | @inlinable 28 | func substring(with range: NSRange) -> String { 29 | (self as NSString).substring(with: range) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/TextAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextAttributes.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | /** 13 | Namespace for additional text attributes supported by `TextEditorView`. 14 | */ 15 | public enum TextAttributes { 16 | /** 17 | A text attributes value for the attachment that can add a suffix image or view to the text range. 18 | */ 19 | public final class SuffixedAttachment: NSObject { 20 | /** 21 | A text attributes key for this value. 22 | 23 | - SeeAlso: 24 | - `NSAttributedString.Key.suffixedAttachment` 25 | */ 26 | public static let attributeName = NSAttributedString.Key(rawValue: "TTESuffixedAttachment") 27 | 28 | /** 29 | An attachment representation. 30 | */ 31 | public enum Attachment { 32 | /** 33 | A still image attachment represented as `UIImage`. 34 | */ 35 | case image(UIImage) 36 | /** 37 | An arbitrary view that represented as `UIView`. 38 | 39 | - Parameters: 40 | - view: A `UIView` that is added to the text editor view. 41 | - layoutInTextContainer: A block that is lay outing given `view` in given `frame`. 42 | It needs to take an account of text editor view's metrics such as `textContentInsets`. 43 | 44 | - SeeAlso: 45 | - `TextEditorView.textContentView` 46 | - `TextEditorView.textContentInsets` 47 | - `TextEditorView.textContentPadding` 48 | */ 49 | case view(view: UIView, layoutInTextContainer: (UIView, CGRect) -> Void) 50 | } 51 | 52 | /** 53 | Size of the attachment. 54 | */ 55 | public let size: CGSize 56 | /** 57 | The attachment representation. 58 | */ 59 | public let attachment: Attachment 60 | 61 | /** 62 | Initialize with an attachment representation. 63 | 64 | - Parameters: 65 | - size: Size of the attachment. 66 | - attachment: An attachment representation. 67 | */ 68 | public init(size: CGSize, attachment: Attachment) { 69 | self.size = size 70 | self.attachment = attachment 71 | 72 | super.init() 73 | } 74 | 75 | /// :nodoc: 76 | public override var description: String { 77 | "<\(type(of: self)): " + 78 | "size = \(size), " + 79 | "attachment = \(attachment)}" + 80 | ">" 81 | } 82 | } 83 | } 84 | 85 | extension NSAttributedString.Key { 86 | /** 87 | A text attributes key for `TextAttributes.SuffixedAttachment`. 88 | */ 89 | public static let suffixedAttachment = TextAttributes.SuffixedAttachment.attributeName 90 | } 91 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/TextEditorViewTextInputTraits.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextEditorViewTextInputTraits.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | /// :nodoc: 13 | extension TextEditorView: UITextInputTraits { 14 | public var autocapitalizationType: UITextAutocapitalizationType { 15 | get { 16 | textView.autocapitalizationType 17 | } 18 | set { 19 | textView.autocapitalizationType = newValue 20 | } 21 | } 22 | 23 | public var autocorrectionType: UITextAutocorrectionType { 24 | get { 25 | textView.autocorrectionType 26 | } 27 | set { 28 | textView.autocorrectionType = newValue 29 | } 30 | } 31 | 32 | public var spellCheckingType: UITextSpellCheckingType { 33 | get { 34 | textView.spellCheckingType 35 | } 36 | set { 37 | textView.spellCheckingType = newValue 38 | } 39 | } 40 | 41 | public var smartQuotesType: UITextSmartQuotesType { 42 | get { 43 | textView.smartQuotesType 44 | } 45 | set { 46 | textView.smartQuotesType = newValue 47 | } 48 | } 49 | 50 | public var smartDashesType: UITextSmartDashesType { 51 | get { 52 | textView.smartDashesType 53 | } 54 | set { 55 | textView.smartDashesType = newValue 56 | } 57 | } 58 | 59 | public var smartInsertDeleteType: UITextSmartInsertDeleteType { 60 | get { 61 | textView.smartInsertDeleteType 62 | } 63 | set { 64 | textView.smartInsertDeleteType = newValue 65 | } 66 | } 67 | 68 | public var keyboardType: UIKeyboardType { 69 | get { 70 | textView.keyboardType 71 | } 72 | set { 73 | textView.keyboardType = newValue 74 | } 75 | } 76 | 77 | public var keyboardAppearance: UIKeyboardAppearance { 78 | get { 79 | textView.keyboardAppearance 80 | } 81 | set { 82 | textView.keyboardAppearance = newValue 83 | } 84 | } 85 | 86 | public var returnKeyType: UIReturnKeyType { 87 | get { 88 | textView.returnKeyType 89 | } 90 | set { 91 | textView.returnKeyType = newValue 92 | } 93 | } 94 | 95 | public var enablesReturnKeyAutomatically: Bool { 96 | get { 97 | textView.enablesReturnKeyAutomatically 98 | } 99 | set { 100 | textView.enablesReturnKeyAutomatically = newValue 101 | } 102 | } 103 | 104 | public var isSecureTextEntry: Bool { 105 | get { 106 | textView.isSecureTextEntry 107 | } 108 | set { 109 | textView.isSecureTextEntry = newValue 110 | } 111 | } 112 | 113 | public var textContentType: UITextContentType! { 114 | get { 115 | textView.textContentType 116 | } 117 | set { 118 | textView.textContentType = newValue 119 | } 120 | } 121 | 122 | @available(iOS 12.0, *) 123 | public var passwordRules: UITextInputPasswordRules? { 124 | get { 125 | textView.passwordRules 126 | } 127 | set { 128 | textView.passwordRules = newValue 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/TextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextView.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import CoreFoundation 10 | import Foundation 11 | import MobileCoreServices 12 | import UIKit 13 | 14 | /** 15 | A delegate for handling text input behaviors. 16 | */ 17 | protocol TextViewTextInputDelegate: AnyObject { 18 | /** 19 | Delegate callback to ask if text view should accept return text input or not. 20 | 21 | - Parameters: 22 | - textView: A `TextView` that changed the base writing direction. 23 | 24 | - Returns: `true` if the text view can accept return text input. 25 | */ 26 | func textViewShouldReturn(_ textView: TextView) -> Bool 27 | 28 | /** 29 | Delegate callback when the text view changed base writing direction. 30 | 31 | - Parameters: 32 | - textView: A `TextView` that changed the base writing direction. 33 | - writingDirection: The current base writing direction. 34 | - range: A range of text where the base writing direction changed. 35 | */ 36 | func textView(_ textView: TextView, 37 | didChangeBaseWritingDirection writingDirection: NSWritingDirection, 38 | forRange range: UITextRange) 39 | } 40 | 41 | /** 42 | A delegate for handling pasting and dropping that extends `UITextPasteDelegate` for `TextView`. 43 | */ 44 | @objc 45 | protocol TextViewTextPasteDelegate: UITextPasteDelegate { 46 | /** 47 | Delegate callback to ask if text view can accept the current paste or drop items or not. 48 | 49 | This delegate callback _SHOULD NOT_ be called for paste or drop items which type identifiers 50 | is not in `pasteConfiguration` acceptable type identifiers. 51 | if this delegate returns `true`, `textPasteConfigurationSupporting(_:transform:)` _SHOULD_ 52 | be called for each paste or drop item. 53 | 54 | - Parameters: 55 | - textPasteConfigurationSupporting: The object that received the paste or drop request. 56 | - itemProviders: A list of `NSItemProvider` for each paste or drop item. 57 | 58 | - Returns: `true` if the text view can accept the current paste or drop items. 59 | 60 | - SeeAlso: 61 | - `UITextPasteDelegate.textPasteConfigurationSupporting(_:transform:)` 62 | */ 63 | @objc 64 | optional func textPasteConfigurationSupporting(_ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, 65 | canPaste itemProviders: [NSItemProvider]) -> Bool 66 | } 67 | 68 | /** 69 | A base text view. 70 | */ 71 | final class TextView: UITextView { 72 | private var preferredTextInputModePrimaryLanguage: String? 73 | 74 | /** 75 | Use given primary language for the preferred text input mode when next time text view becomes 76 | first responder. 77 | 78 | - Parameters: 79 | - primaryLanguage: `String` represents a primary language for the preferred text input mode. 80 | Use `"emoji"` to use Emoji keyboard. Set `nil` to default keyboard. 81 | */ 82 | func usePreferredTextInputModePrimaryLanguage(_ primaryLanguage: String?) { 83 | preferredTextInputModePrimaryLanguage = primaryLanguage 84 | } 85 | 86 | /* 87 | UIKit bug workaround 88 | 89 | - Confirmed on iOS 13.0 to iOS 13.3. 90 | - Fixed on iOS 13.4. 91 | 92 | `textInputMode` override is completely ignored on these version of iOS 13 due to bug in 93 | `-[UIKeyboardImpl recomputeActiveInputModesWithExtensions:allowNonLinguisticInputModes:]`, 94 | which has a flipped condition check, which doesn't always call `-[UIKeyboardImpl setInputMode:userInitiated:]`. 95 | To workaround this behavior, return non `nil` identifier from `textInputContextIdentifier` 96 | to call `-[UIKeyboardImpl setInputMode:userInitiated:]` from `-[UIKeyboardInputModeController _trackInputModeIfNecessary:]` 97 | and bypass `-[UIKeyboardImpl recomputeActiveInputModesWithExtensions:allowNonLinguisticInputModes:]` call. 98 | Also need to clean up text input context identifier once it’s used for the bug workaround. 99 | 100 | - SeeAlso: 101 | - `becomeFirstResponder()` 102 | - `textInputContextIdentifier` 103 | - `textInputMode` 104 | */ 105 | 106 | private let shouldWorkaroundTextInputModeBug: Bool = { 107 | // iOS 13.0 to iOS 13.3 108 | if #available(iOS 13.0, *) { 109 | if #available(iOS 13.4, *) { 110 | return false 111 | } else { 112 | return true 113 | } 114 | } 115 | return false 116 | }() 117 | 118 | /** 119 | Enable changing text writing direction from user actions. 120 | Default to `false`. 121 | 122 | - SeeAlso: 123 | - `canPerformAction(_:withSender:)` 124 | */ 125 | var changeTextWritingDirectionActionsEnabled: Bool = false 126 | 127 | /** 128 | A thin cache for observing the paste board. 129 | 130 | This logic is a same logic as how `UITextView` caches if the paste board has 131 | text that can be pasted. 132 | 133 | - SeeAlso: 134 | - `-[UITextInputController _pasteboardHasStrings]`. 135 | */ 136 | private class TextInputPasteboardObserverCache { 137 | private var cache: (value: T, time: CFAbsoluteTime)? 138 | 139 | private var pasteboardChangedNotificationObserverToken: NotificationObserverToken? 140 | private var applicationWillEnterForegroundNotificationObserverToken: NotificationObserverToken? 141 | 142 | func invalidate() { 143 | cache = nil 144 | } 145 | 146 | func cached(_ block: () -> T) -> T { 147 | if pasteboardChangedNotificationObserverToken == nil { 148 | pasteboardChangedNotificationObserverToken = 149 | NotificationCenter.default.addObserver(forName: UIPasteboard.changedNotification, 150 | object: nil, 151 | queue: nil) { [weak self] _ in 152 | self?.cache = nil 153 | } 154 | } 155 | if applicationWillEnterForegroundNotificationObserverToken == nil { 156 | applicationWillEnterForegroundNotificationObserverToken = 157 | NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, 158 | object: nil, 159 | queue: nil) { [weak self] _ in 160 | self?.cache = nil 161 | } 162 | } 163 | 164 | let now = CFAbsoluteTimeGetCurrent() 165 | if let cache = cache, now - cache.time < 1.0 { 166 | return cache.value 167 | } 168 | 169 | let value = block() 170 | cache = (value, now) 171 | 172 | return value 173 | } 174 | } 175 | 176 | private let pasteboardObserverCache = TextInputPasteboardObserverCache() 177 | 178 | override var pasteConfiguration: UIPasteConfiguration? { 179 | get { 180 | super.pasteConfiguration 181 | } 182 | set { 183 | super.pasteConfiguration = newValue 184 | pasteboardObserverCache.invalidate() 185 | } 186 | } 187 | 188 | // MARK: - UIResponder 189 | 190 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 191 | if !changeTextWritingDirectionActionsEnabled, 192 | action == #selector(makeTextWritingDirectionLeftToRight(_:)) || 193 | action == #selector(makeTextWritingDirectionRightToLeft(_:)) 194 | { 195 | return false 196 | } 197 | 198 | if super.canPerformAction(action, withSender: sender) { 199 | return true 200 | } 201 | 202 | /* 203 | UIKit behavior workaround 204 | 205 | - Confirmed on iOS 13.6. 206 | 207 | `UITextView`'s default `canPerformAction(_:withSender:)` doesn't use `UIPasteConfigurationSupporting`'s 208 | `canPaste(_:)` but only for the drop interaction, even if actual `paste(_:)` will use its delegate. 209 | 210 | Basically, it is returns `false` if `-[UIPasteboard hasStrings]` is `false`. 211 | To accept pasting any acceptable type identifier that allowed in `pasteConfiguration` 212 | to have a consistent behavior with drag and drop. 213 | 214 | - SeeAlso: 215 | - `canPaste(_:)` 216 | - `-[UIResponder canPasteItemProviders:]` 217 | - `+[UITextInputController _pasteboardHasStrings]` 218 | - `-[UITextInputController _shouldHandleResponderAction:]` 219 | */ 220 | if action == #selector(paste(_:)), 221 | let acceptableTypeIdentifiers = self.pasteConfiguration?.acceptableTypeIdentifiers 222 | { 223 | // Using a thin cache to remember the result for same paste board state. 224 | // This is important because `canPerform(_:withSender:)` is called frequently. 225 | return pasteboardObserverCache.cached { 226 | // This is the same logic as default `canPaste(_:)`, which implementation is matching 227 | // `registeredTypeIdentifiers` and `acceptableTypeIdentifiers` by using `UTTypeConformsTo()`. 228 | // See `-[UIResponder canPasteItemProviders:]`. 229 | let isPasteboardConformingAcceptableType = 230 | acceptableTypeIdentifiers.contains { acceptableTypeIdentifier in 231 | UIPasteboard.general.types.contains { type in 232 | UTTypeConformsTo(type as CFString, acceptableTypeIdentifier as CFString) 233 | } 234 | } 235 | if isPasteboardConformingAcceptableType { 236 | if let delegate = pasteDelegate as? TextViewTextPasteDelegate { 237 | let itemProviders = UIPasteboard.general.itemProviders 238 | if let result = delegate.textPasteConfigurationSupporting?(self, canPaste: itemProviders) { 239 | return result 240 | } 241 | } 242 | return true 243 | } 244 | return false 245 | } 246 | } 247 | 248 | return false 249 | } 250 | 251 | private let preferredTextInputModeContextIdentifier = ".TTETwitterTextEditorTextViewPreferredInputModeContextIdentifier" 252 | 253 | @discardableResult 254 | override func becomeFirstResponder() -> Bool { 255 | let result = super.becomeFirstResponder() 256 | if result { 257 | if shouldWorkaroundTextInputModeBug { 258 | log(type: .debug, "Clear text input context identifier: %@", preferredTextInputModeContextIdentifier) 259 | UIResponder.clearTextInputContextIdentifier(preferredTextInputModeContextIdentifier) 260 | } 261 | preferredTextInputModePrimaryLanguage = nil 262 | } 263 | return result 264 | } 265 | 266 | // MARK: - UIResponder (UIResponderInputViewAdditions) 267 | 268 | override var textInputContextIdentifier: String? { 269 | if shouldWorkaroundTextInputModeBug, preferredTextInputModePrimaryLanguage != nil { 270 | log(type: .debug, "Use text input context identifier: %@", preferredTextInputModeContextIdentifier) 271 | return preferredTextInputModeContextIdentifier 272 | } 273 | 274 | return super.textInputContextIdentifier 275 | } 276 | 277 | override var textInputMode: UITextInputMode? { 278 | if let preferredTextInputModePrimaryLanguage = preferredTextInputModePrimaryLanguage, 279 | let inputMode = UITextInputMode.activeInputModes.first(where: { inputMode in 280 | inputMode.primaryLanguage == preferredTextInputModePrimaryLanguage 281 | }) 282 | { 283 | log(type: .debug, "Text input mode: %@", inputMode) 284 | return inputMode 285 | } 286 | 287 | return super.textInputMode 288 | } 289 | 290 | // MARK: - UITextInput 291 | 292 | weak var textViewTextInputDelegate: TextViewTextInputDelegate? 293 | 294 | // Only `U+000A` ("\n") is a valid text that return text input insert. 295 | private let returnTextInputInsertText = "\n" 296 | 297 | override func insertText(_ text: String) { 298 | if text == returnTextInputInsertText, 299 | let textViewTextInputDelegate = textViewTextInputDelegate, 300 | !textViewTextInputDelegate.textViewShouldReturn(self) 301 | { 302 | return 303 | } 304 | 305 | super.insertText(text) 306 | } 307 | 308 | override func deleteBackward() { 309 | /* 310 | UIKit bug workaround 311 | 312 | - Confirmed on iOS 13.7. 313 | - Confirmed macCatalyst 13. 314 | - Confirmed on iOS 12. 315 | 316 | On iOS 13 and later, when `isEditable` is `false`, with hardware keyboard delete key 317 | (not backspace key) `UITextView.deleteBackward()` is called and it actually deletes the content. 318 | 319 | To prevent content is edited while `isEditable` is `false`, override it and do nothing if it's not `true`. 320 | */ 321 | if #available(iOS 13.0, *) { 322 | guard isEditable else { 323 | return 324 | } 325 | } 326 | 327 | super.deleteBackward() 328 | } 329 | 330 | override func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) { 331 | super.setBaseWritingDirection(writingDirection, for: range) 332 | 333 | textViewTextInputDelegate?.textView(self, didChangeBaseWritingDirection: writingDirection, forRange: range) 334 | } 335 | 336 | // MARK: - UIPasteConfigurationSupporting 337 | 338 | override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { 339 | /* 340 | UIKit behavior note 341 | 342 | - Confirmed on iOS 13.6. 343 | 344 | This is called only for drop interaction from `dropInteraction:canHandleSession:` 345 | of the internal `UIDropInteractionDelegate` of `UITextView`, `UITextDragAssistant`. 346 | 347 | `canPaste(_:)` is called first, then other `UITextDropDelegate` delegate callbacks are called 348 | such as `textDroppableView(_:proposalForDrop:)`. 349 | 350 | - SeeAlso: 351 | - `-[UITextDragAssistant dropInteraction:canHandleSession:]` 352 | */ 353 | 354 | // This logic is the same logic as for `paste(_:)` action in `canPerformAction(_:withSender:)`. 355 | // See `-[UIResponder canPasteItemProviders:]`. 356 | if super.canPaste(itemProviders) { 357 | if let delegate = pasteDelegate as? TextViewTextPasteDelegate { 358 | if let result = delegate.textPasteConfigurationSupporting?(self, canPaste: itemProviders) { 359 | return result 360 | } 361 | } 362 | return true 363 | } 364 | return false 365 | } 366 | 367 | // MARK: - NSView 368 | 369 | #if targetEnvironment(macCatalyst) 370 | /* 371 | macCatalyst UIKit behavior workaround 372 | 373 | - Confirmed macCatalyst 13.x and macCatalyst 14 beta 374 | - see Bool { 23 | return textViewDelegate?.textViewShouldBeginEditing?(textView) ?? false 24 | } 25 | 26 | public func textViewDidBeginEditing(_ textView: UITextView) { 27 | textViewDelegate?.textViewDidBeginEditing?(textView) 28 | } 29 | 30 | public func textViewDidEndEditing(_ textView: UITextView) { 31 | textViewDelegate?.textViewDidEndEditing?(textView) 32 | } 33 | 34 | public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { 35 | return textViewDelegate?.textView?(textView, shouldChangeTextIn: range, replacementText: text) ?? true 36 | } 37 | 38 | public func textViewDidChangeSelection(_ textView: UITextView) { 39 | textViewDelegate?.textViewDidChangeSelection?(textView) 40 | } 41 | 42 | public func textViewDidChange(_ textView: UITextView) { 43 | textViewDelegate?.textViewDidChange?(textView) 44 | } 45 | } 46 | 47 | /// :nodoc: 48 | extension TextViewDelegateForwarder: UIScrollViewDelegate { 49 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 50 | scrollViewDelegate?.scrollViewDidScroll?(scrollView) 51 | } 52 | func scrollViewDidZoom(_ scrollView: UIScrollView) { 53 | scrollViewDelegate?.scrollViewDidZoom?(scrollView) 54 | } 55 | 56 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 57 | scrollViewDelegate?.scrollViewWillBeginDragging?(scrollView) 58 | } 59 | func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { 60 | scrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset) 61 | } 62 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { 63 | scrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) 64 | } 65 | 66 | func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { 67 | scrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView) 68 | } 69 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { 70 | scrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView) 71 | } 72 | 73 | func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { 74 | scrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) 75 | } 76 | 77 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { 78 | return scrollViewDelegate?.viewForZooming?(in: scrollView) 79 | } 80 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { 81 | scrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view) 82 | } 83 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { 84 | scrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale) 85 | } 86 | 87 | func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { 88 | // If the delegate doesn’t implement this method, `true` is assumed. 89 | return scrollViewDelegate?.scrollViewShouldScrollToTop?(scrollView) ?? true 90 | } 91 | func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { 92 | scrollViewDelegate?.scrollViewDidScrollToTop?(scrollView) 93 | } 94 | 95 | func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { 96 | scrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/Tracer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tracer.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | A signpost interface used in this module. 13 | 14 | Compatible with `OSSignpostType`. 15 | 16 | - SeeAlso: 17 | - `OSSignpostType` 18 | */ 19 | public protocol Signpost { 20 | /** 21 | Marks the start of a time interval of the tracing. 22 | */ 23 | func begin() 24 | 25 | /** 26 | Marks the end of a time interval of the tracing. 27 | */ 28 | func end() 29 | 30 | /** 31 | Marks an event of the tracing. 32 | */ 33 | func event() 34 | } 35 | 36 | /** 37 | A tracing interface that creates a new `Signpost`. 38 | */ 39 | public protocol Tracer { 40 | /** 41 | Create a new signpost with message for tracing. 42 | 43 | - Parameters: 44 | - name: Name of signpost. 45 | - file: Source file path to the logging position. 46 | - line: Line to the logging position in the `file`. 47 | - function: Function name of the logging position. 48 | - message: Log message. 49 | 50 | - Returns: A new `Signpost` for the tracing. 51 | */ 52 | func signpost(name: StaticString, 53 | file: StaticString, 54 | line: Int, 55 | function: StaticString, 56 | message: @autoclosure () -> String?) -> Signpost 57 | } 58 | 59 | private struct NoSignpost: Signpost { 60 | func begin() { 61 | } 62 | 63 | func end() { 64 | } 65 | 66 | func event() { 67 | } 68 | } 69 | 70 | /** 71 | Default function to create a signpost. 72 | 73 | - Parameters: 74 | - name: Name of signpost. 75 | - file: Source file path to the logging position. Default to `#file`. 76 | - line: Line to the logging position in the `file`. Default to `#line` 77 | - function: Function name of the logging position. Default to `#function`. 78 | - message: Log message. Default to `nil`. 79 | 80 | - Returns: A `Signpost` for the tracing. 81 | */ 82 | func signpost(name: StaticString, 83 | file: StaticString = #file, 84 | line: Int = #line, 85 | function: StaticString = #function, 86 | _ message: @autoclosure () -> String? = nil) -> Signpost 87 | { 88 | guard let tracer = Configuration.shared.tracer else { 89 | return NoSignpost() 90 | } 91 | 92 | return tracer.signpost(name: name, 93 | file: file, 94 | line: line, 95 | function: function, 96 | message: message()) 97 | } 98 | 99 | /** 100 | Create a signpost with a format. 101 | 102 | - Parameters: 103 | - name: Name of signpost. 104 | - file: Source file path to the logging position. Default to `#file`. 105 | - line: Line to the logging position in the `file`. Default to `#line` 106 | - function: Function name of the logging position. Default to `#function`. 107 | - format: Log message format. 108 | - arguments: Arguments for `format`. 109 | 110 | - Returns: A `Signpost` for the tracing. 111 | */ 112 | func signpost(name: StaticString, 113 | file: StaticString = #file, 114 | line: Int = #line, 115 | function: StaticString = #function, 116 | _ format: String? = nil, 117 | _ arguments: CVarArg...) -> Signpost 118 | { 119 | signpost(name: name, 120 | file: file, 121 | line: line, 122 | function: function, 123 | format.map { String(format: $0, arguments: arguments) }) 124 | } 125 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/UIResponder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIResponder.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIResponder { 13 | /** 14 | Returns if the responder is using dictation or not. 15 | 16 | - SeeAlso: 17 | - `UITextInput.hasMarkedText` 18 | */ 19 | var isDictationRecording: Bool { 20 | // TODO: Probably need to take an account of `preferredTextInputModePrimaryLanguage`. 21 | textInputMode?.primaryLanguage == "dictation" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/TwitterTextEditor/UITextInput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITextInput.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UITextInput { 13 | /** 14 | Returns if the text input has marked text or not. 15 | 16 | - SeeAlso: 17 | - `UIResponder.isDictationRecording` 18 | */ 19 | var hasMarkedText: Bool { 20 | markedTextRange != nil 21 | } 22 | 23 | /** 24 | Returns a rect of caret at the beginning of the current selected range. 25 | Useful to find the current cursor position. 26 | 27 | `nil` if there is no selected range. 28 | */ 29 | var caretRectAtBeginningOfSelectedRange: CGRect? { 30 | guard let selectedTextRange = selectedTextRange else { 31 | return nil 32 | } 33 | 34 | return caretRect(for: selectedTextRange.start) 35 | } 36 | 37 | /** 38 | Returns a rect of caret at the end of the current selected range. 39 | Useful to find the current cursor position. 40 | 41 | `nil` if there is no selected range. 42 | */ 43 | var caretRectAtEndOfSelectedRange: CGRect? { 44 | guard let selectedTextRange = selectedTextRange else { 45 | return nil 46 | } 47 | 48 | return caretRect(for: selectedTextRange.end) 49 | } 50 | 51 | /** 52 | Returns a rect used for presenting a menu which has items such as copy and paste 53 | for the current selected range. 54 | Useful to present a menu manually. 55 | 56 | `nil` if there is no selected range. 57 | */ 58 | var menuRectAtSelectedRange: CGRect? { 59 | guard let selectedTextRange = selectedTextRange else { 60 | return nil 61 | } 62 | 63 | let selectedRangeLength = offset(from: selectedTextRange.start, to: selectedTextRange.end) 64 | if selectedRangeLength > 0 { 65 | // The menu is by default appearing at the middle of first rect of selected range. 66 | return firstRect(for: selectedTextRange) 67 | } else { 68 | // The menu is by default appearing at the caret position. 69 | return caretRect(for: selectedTextRange.start) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/TwitterTextEditorTests/EditingContentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditingContentTests.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | @testable import TwitterTextEditor 11 | import XCTest 12 | 13 | final class EditingContentTests: XCTestCase { 14 | func testInitializeWithInvalidRange() { 15 | XCTAssertThrowsError(try EditingContent(text: "meow", selectedRange: .null)) 16 | } 17 | 18 | func testInitializeWithOutOfSelectedRange() { 19 | XCTAssertThrowsError(try EditingContent(text: "meow", selectedRange: NSRange(location: 0, length: 5))) 20 | } 21 | 22 | // MARK: - 23 | 24 | func testUpdateWithNullRequest() throws { 25 | let content = try EditingContent(text: "meow", selectedRange: .zero) 26 | let request = EditingContent.UpdateRequest.null 27 | 28 | let updatedContent = try content.update(with: request) 29 | XCTAssertEqual(content, updatedContent) 30 | } 31 | 32 | func testUpdateWithText() throws { 33 | let content = try EditingContent(text: "meow", selectedRange: .zero) 34 | let request = EditingContent.UpdateRequest.text("purr") 35 | 36 | let updatedContent = try content.update(with: request) 37 | XCTAssertEqual(updatedContent.text, "purr") 38 | XCTAssertEqual(updatedContent.selectedRange, .zero) 39 | } 40 | 41 | func testUpdateWithTextAndOutOfSelectedRange() throws { 42 | let content = try EditingContent(text: "meow", selectedRange: .zero) 43 | let request = EditingContent.UpdateRequest.text("purr", selectedRange: NSRange(location: 5, length: 0)) 44 | 45 | XCTAssertThrowsError(try content.update(with: request)) 46 | } 47 | 48 | func testUpdateWithSubtextAndOutOfReplacingRange() throws { 49 | let content = try EditingContent(text: "meow", selectedRange: .zero) 50 | let request = EditingContent.UpdateRequest.subtext(range: NSRange(location: 5, length: 0), text: "purr") 51 | 52 | XCTAssertThrowsError(try content.update(with: request)) 53 | } 54 | 55 | func testUpdateWithSubtext() throws { 56 | let content = try EditingContent(text: "meow", selectedRange: .zero) 57 | let request = EditingContent.UpdateRequest.subtext(range: .zero, text: "purr") 58 | 59 | let updatedContent = try content.update(with: request) 60 | XCTAssertEqual(updatedContent.text, "purrmeow") 61 | XCTAssertEqual(updatedContent.selectedRange, NSRange(location: 4, length: 0)) 62 | } 63 | 64 | func testUpdateWithSubtextAndOutOfSelectedRange() throws { 65 | let content = try EditingContent(text: "meow", selectedRange: .zero) 66 | let request = EditingContent.UpdateRequest.subtext(range: .zero, text: "purr", selectedRange: NSRange(location: 9, length: 0)) 67 | 68 | XCTAssertThrowsError(try content.update(with: request)) 69 | } 70 | 71 | func testUpdateWithSubtextAndSelectedRange() throws { 72 | let content = try EditingContent(text: "meow", selectedRange: .zero) 73 | let request = EditingContent.UpdateRequest.subtext(range: .zero, text: "purr", selectedRange: NSRange(location: 8, length: 0)) 74 | 75 | let updatedContent = try content.update(with: request) 76 | XCTAssertEqual(updatedContent.text, "purrmeow") 77 | XCTAssertEqual(updatedContent.selectedRange, NSRange(location: 8, length: 0)) 78 | } 79 | 80 | func testUpdateWithTextAndSelectedRange() throws { 81 | let content = try EditingContent(text: "meow", selectedRange: .zero) 82 | let request = EditingContent.UpdateRequest.text("purr", selectedRange: NSRange(location: 4, length: 0)) 83 | 84 | let updatedContent = try content.update(with: request) 85 | XCTAssertEqual(updatedContent.text, "purr") 86 | XCTAssertEqual(updatedContent.selectedRange, NSRange(location: 4, length: 0)) 87 | } 88 | 89 | func testUpdateWithOutOfSelectedRange() throws { 90 | let content = try EditingContent(text: "meow", selectedRange: .zero) 91 | let request = EditingContent.UpdateRequest.selectedRange(NSRange(location: 5, length: 0)) 92 | 93 | XCTAssertThrowsError(try content.update(with: request)) 94 | } 95 | 96 | func testUpdateWithSelectedRange() throws { 97 | let content = try EditingContent(text: "meow", selectedRange: .zero) 98 | let request = EditingContent.UpdateRequest.selectedRange(NSRange(location: 4, length: 0)) 99 | 100 | let updatedContent = try content.update(with: request) 101 | XCTAssertEqual(updatedContent.text, "meow") 102 | XCTAssertEqual(updatedContent.selectedRange, NSRange(location: 4, length: 0)) 103 | } 104 | 105 | // MARK: - 106 | 107 | func testChangeResultWithoutChange() throws { 108 | let content = try EditingContent(text: "meow", selectedRange: .zero) 109 | let changedContent = content 110 | 111 | XCTAssertNil(content.changeResult(from: changedContent)) 112 | } 113 | 114 | func testChangeResultWithTextChange() throws { 115 | let content = try EditingContent(text: "meow", selectedRange: .zero) 116 | let changedContent = try EditingContent(text: "purr", selectedRange: .zero) 117 | 118 | let changeResult = try XCTUnwrap(content.changeResult(from: changedContent)) 119 | XCTAssertTrue(changeResult.isTextChanged) 120 | XCTAssertFalse(changeResult.isSelectedRangeChanged) 121 | } 122 | 123 | func testChangeResultWithSelectedRangeChange() throws { 124 | let content = try EditingContent(text: "meow", selectedRange: .zero) 125 | let changedContent = try EditingContent(text: "meow", selectedRange: NSRange(location: 1, length: 0)) 126 | 127 | let changeResult = try XCTUnwrap(content.changeResult(from: changedContent)) 128 | XCTAssertFalse(changeResult.isTextChanged) 129 | XCTAssertTrue(changeResult.isSelectedRangeChanged) 130 | } 131 | 132 | func testChangeResultWithTextAndSelectedRangeChange() throws { 133 | let content = try EditingContent(text: "meow", selectedRange: .zero) 134 | let changedContent = try EditingContent(text: "purr", selectedRange: NSRange(location: 1, length: 0)) 135 | 136 | let changeResult = try XCTUnwrap(content.changeResult(from: changedContent)) 137 | XCTAssertTrue(changeResult.isTextChanged) 138 | XCTAssertTrue(changeResult.isSelectedRangeChanged) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Tests/TwitterTextEditorTests/NSRangeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSRangeTests.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | @testable import TwitterTextEditor 11 | import XCTest 12 | 13 | final class NSRangeTests: XCTestCase { 14 | // |0|1|2|3|4|5|6| 15 | // |-----| 16 | // |=| 17 | func testRangeWithLengthMovedByReplacingRangeWithLengthRangeBelowLowerBound() { 18 | XCTAssertEqual( 19 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 0, length: 1), length: 0), 20 | NSRange(location: 1, length: 3) 21 | ) 22 | XCTAssertEqual( 23 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 0, length: 1), length: 1), 24 | NSRange(location: 2, length: 3) 25 | ) 26 | XCTAssertEqual( 27 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 0, length: 1), length: 2), 28 | NSRange(location: 3, length: 3) 29 | ) 30 | } 31 | 32 | // |0|1|2|3|4|5|6| 33 | // |-----| 34 | // | 35 | func testRangeWithLengthMovedByReplacingRangeWithoutLengthBelowLowerBound() { 36 | XCTAssertEqual( 37 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 0), length: 0), 38 | NSRange(location: 2, length: 3) 39 | ) 40 | XCTAssertEqual( 41 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 0), length: 1), 42 | NSRange(location: 3, length: 3) 43 | ) 44 | } 45 | 46 | // |0|1|2|3|4|5|6| 47 | // |-----| 48 | // |=| 49 | func testRangeWithLengthMovedByReplacingRangeWithLengthAtLowerBound() { 50 | XCTAssertEqual( 51 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 1), length: 0), 52 | NSRange(location: 1, length: 3) 53 | ) 54 | XCTAssertEqual( 55 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 1), length: 1), 56 | NSRange(location: 2, length: 3) 57 | ) 58 | XCTAssertEqual( 59 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 1), length: 2), 60 | NSRange(location: 3, length: 3) 61 | ) 62 | } 63 | 64 | // |0|1|2|3|4|5|6| 65 | // |-----| 66 | // | 67 | func testRangeWithLengthMovedByReplacingRangeWithoutLengthAtLowerBound() { 68 | XCTAssertEqual( 69 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 2, length: 0), length: 0), 70 | NSRange(location: 2, length: 3) 71 | ) 72 | XCTAssertEqual( 73 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 2, length: 0), length: 1), 74 | NSRange(location: 3, length: 3) 75 | ) 76 | } 77 | 78 | // |0|1|2|3|4|5|6| 79 | // |-----| 80 | // |===| 81 | func testRangeWithLengthMovedByReplacingRangeWithLengthOverLowerBound() { 82 | XCTAssertEqual( 83 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 2), length: 1), 84 | NSRange(location: 1, length: 3) 85 | ) 86 | XCTAssertEqual( 87 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 2), length: 2), 88 | NSRange(location: 1, length: 4) 89 | ) 90 | XCTAssertEqual( 91 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 1, length: 2), length: 3), 92 | NSRange(location: 1, length: 5) 93 | ) 94 | } 95 | 96 | // |0|1|2|3|4|5|6| 97 | // |-----| 98 | // |=| 99 | func testRangeWithLengthMovedByReplacingRangeWithLengthAtLowerBoundInBound() { 100 | XCTAssertEqual( 101 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 2, length: 1), length: 0), 102 | NSRange(location: 2, length: 2) 103 | ) 104 | XCTAssertEqual( 105 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 2, length: 1), length: 1), 106 | NSRange(location: 2, length: 3) 107 | ) 108 | XCTAssertEqual( 109 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 2, length: 1), length: 2), 110 | NSRange(location: 2, length: 4) 111 | ) 112 | } 113 | 114 | // |0|1|2|3|4|5|6| 115 | // |-----| 116 | // | 117 | func testRangeWithLengthMovedByReplacingRangeWithoutLengthAtLowerInBound() { 118 | XCTAssertEqual( 119 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 3, length: 0), length: 0), 120 | NSRange(location: 2, length: 3) 121 | ) 122 | XCTAssertEqual( 123 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 3, length: 0), length: 1), 124 | NSRange(location: 2, length: 4) 125 | ) 126 | } 127 | 128 | // |0|1|2|3|4|5|6| 129 | // |-----| 130 | // |=| 131 | func testRangeWithLengthMovedByReplacingRangeWithLengthInBound() { 132 | XCTAssertEqual( 133 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 3, length: 1), length: 0), 134 | NSRange(location: 2, length: 2) 135 | ) 136 | XCTAssertEqual( 137 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 3, length: 1), length: 1), 138 | NSRange(location: 2, length: 3) 139 | ) 140 | XCTAssertEqual( 141 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 3, length: 1), length: 2), 142 | NSRange(location: 2, length: 4) 143 | ) 144 | } 145 | 146 | // |0|1|2|3|4|5|6| 147 | // |-----| 148 | // | 149 | func testRangeWithLengthMovedByReplacingRangeWithoutLengthAtUpperInBound() { 150 | XCTAssertEqual( 151 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 0), length: 0), 152 | NSRange(location: 2, length: 3) 153 | ) 154 | XCTAssertEqual( 155 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 0), length: 1), 156 | NSRange(location: 2, length: 4) 157 | ) 158 | } 159 | 160 | // |0|1|2|3|4|5|6| 161 | // |-----| 162 | // |=| 163 | func testRangeWithLengthMovedByReplacingRangeWithLengthAtUpperBoundInBound() { 164 | XCTAssertEqual( 165 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 1), length: 0), 166 | NSRange(location: 2, length: 2) 167 | ) 168 | XCTAssertEqual( 169 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 1), length: 1), 170 | NSRange(location: 2, length: 3) 171 | ) 172 | XCTAssertEqual( 173 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 1), length: 2), 174 | NSRange(location: 2, length: 4) 175 | ) 176 | } 177 | 178 | // |0|1|2|3|4|5|6| 179 | // |-----| 180 | // |===| 181 | func testRangeWithLengthMovedByReplacingRangeWithLengthOverUpperBound() { 182 | XCTAssertEqual( 183 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 2), length: 1), 184 | NSRange(location: 2, length: 3) 185 | ) 186 | XCTAssertEqual( 187 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 2), length: 2), 188 | NSRange(location: 2, length: 4) 189 | ) 190 | XCTAssertEqual( 191 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 4, length: 2), length: 3), 192 | NSRange(location: 2, length: 5) 193 | ) 194 | } 195 | 196 | // |0|1|2|3|4|5|6| 197 | // |-----| 198 | // | 199 | func testRangeWithLengthMovedByReplacingRangeWithoutLengthAtUpperBound() { 200 | XCTAssertEqual( 201 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 5, length: 0), length: 0), 202 | NSRange(location: 2, length: 3) 203 | ) 204 | XCTAssertEqual( 205 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 5, length: 0), length: 1), 206 | NSRange(location: 2, length: 3) 207 | ) 208 | } 209 | 210 | // |0|1|2|3|4|5|6| 211 | // |-----| 212 | // |=| 213 | func testRangeWithLengthMovedByReplacingRangeWithLengthAtUpperBound() { 214 | XCTAssertEqual( 215 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 5, length: 1), length: 0), 216 | NSRange(location: 2, length: 3) 217 | ) 218 | XCTAssertEqual( 219 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 5, length: 1), length: 1), 220 | NSRange(location: 2, length: 3) 221 | ) 222 | XCTAssertEqual( 223 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 5, length: 1), length: 2), 224 | NSRange(location: 2, length: 3) 225 | ) 226 | } 227 | 228 | // |0|1|2|3|4|5|6| 229 | // |-----| 230 | // | 231 | func testRangeWithLengthMovedByReplacingRangeWithoutLengthAboveUpperBound() { 232 | XCTAssertEqual( 233 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 6, length: 1), length: 0), 234 | NSRange(location: 2, length: 3) 235 | ) 236 | XCTAssertEqual( 237 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 6, length: 1), length: 1), 238 | NSRange(location: 2, length: 3) 239 | ) 240 | XCTAssertEqual( 241 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 6, length: 1), length: 2), 242 | NSRange(location: 2, length: 3) 243 | ) 244 | } 245 | 246 | // |0|1|2|3|4|5|6| 247 | // |-----| 248 | // |=| 249 | func testRangeWithLengthMovedByReplacingRangeWithLengthAboveUpperBound() { 250 | XCTAssertEqual( 251 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 6, length: 1), length: 0), 252 | NSRange(location: 2, length: 3) 253 | ) 254 | XCTAssertEqual( 255 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 6, length: 1), length: 1), 256 | NSRange(location: 2, length: 3) 257 | ) 258 | XCTAssertEqual( 259 | NSRange(location: 2, length: 3).movedByReplacing(range: NSRange(location: 6, length: 1), length: 2), 260 | NSRange(location: 2, length: 3) 261 | ) 262 | } 263 | 264 | // MARK: - 265 | 266 | // |0|1|2|3|4|5|6| 267 | // | 268 | // |=======| 269 | func testRangeWithoutLengthBelowLowerBoundMovedByReplacingRange() { 270 | XCTAssertEqual( 271 | NSRange(location: 0, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 0), 272 | NSRange(location: 0, length: 0) 273 | ) 274 | XCTAssertEqual( 275 | NSRange(location: 0, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 4), 276 | NSRange(location: 0, length: 0) 277 | ) 278 | XCTAssertEqual( 279 | NSRange(location: 0, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 8), 280 | NSRange(location: 0, length: 0) 281 | ) 282 | } 283 | 284 | // |0|1|2|3|4|5|6| 285 | // | 286 | // |=======| 287 | func testRangeWithoutLengthOnLowerBoundMovedByReplacingRange() { 288 | XCTAssertEqual( 289 | NSRange(location: 1, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 0), 290 | NSRange(location: 1, length: 0) 291 | ) 292 | XCTAssertEqual( 293 | NSRange(location: 1, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 4), 294 | NSRange(location: 1, length: 0) 295 | ) 296 | XCTAssertEqual( 297 | NSRange(location: 1, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 8), 298 | NSRange(location: 1, length: 0) 299 | ) 300 | } 301 | 302 | // |0|1|2|3|4|5|6| 303 | // | 304 | // |=======| 305 | func testRangeWithoutLengthInBoundBelowMiddleMovedByReplacingRange() { 306 | XCTAssertEqual( 307 | NSRange(location: 2, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 0), 308 | NSRange(location: 1, length: 0) 309 | ) 310 | XCTAssertEqual( 311 | NSRange(location: 2, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 4), 312 | NSRange(location: 1, length: 0) 313 | ) 314 | XCTAssertEqual( 315 | NSRange(location: 2, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 8), 316 | NSRange(location: 1, length: 0) 317 | ) 318 | } 319 | 320 | // |0|1|2|3|4|5|6| 321 | // | 322 | // |=======| 323 | func testRangeWithoutLengthInBoundAtMiddleMovedByReplacingRange() { 324 | XCTAssertEqual( 325 | NSRange(location: 3, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 0), 326 | NSRange(location: 1, length: 0) 327 | ) 328 | XCTAssertEqual( 329 | NSRange(location: 3, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 4), 330 | NSRange(location: 5, length: 0) 331 | ) 332 | XCTAssertEqual( 333 | NSRange(location: 3, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 8), 334 | NSRange(location: 9, length: 0) 335 | ) 336 | } 337 | 338 | // |0|1|2|3|4|5|6| 339 | // | 340 | // |=======| 341 | func testRangeWithoutLengthInBoundAboveMiddleMovedByReplacingRange() { 342 | XCTAssertEqual( 343 | NSRange(location: 4, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 0), 344 | NSRange(location: 1, length: 0) 345 | ) 346 | XCTAssertEqual( 347 | NSRange(location: 4, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 4), 348 | NSRange(location: 5, length: 0) 349 | ) 350 | XCTAssertEqual( 351 | NSRange(location: 4, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 8), 352 | NSRange(location: 9, length: 0) 353 | ) 354 | } 355 | 356 | // |0|1|2|3|4|5|6| 357 | // | 358 | // |=======| 359 | func testRangeWithoutLengthOnUpperBoundMovedByReplacingRange() { 360 | XCTAssertEqual( 361 | NSRange(location: 5, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 0), 362 | NSRange(location: 1, length: 0) 363 | ) 364 | XCTAssertEqual( 365 | NSRange(location: 5, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 4), 366 | NSRange(location: 5, length: 0) 367 | ) 368 | XCTAssertEqual( 369 | NSRange(location: 5, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 8), 370 | NSRange(location: 9, length: 0) 371 | ) 372 | } 373 | 374 | // |0|1|2|3|4|5|6| 375 | // | 376 | // |=======| 377 | func testRangeWithoutLengthAboveUpperBoundMovedByReplacingRange() { 378 | XCTAssertEqual( 379 | NSRange(location: 6, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 0), 380 | NSRange(location: 2, length: 0) 381 | ) 382 | XCTAssertEqual( 383 | NSRange(location: 6, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 4), 384 | NSRange(location: 6, length: 0) 385 | ) 386 | XCTAssertEqual( 387 | NSRange(location: 6, length: 0).movedByReplacing(range: NSRange(location: 1, length: 4), length: 8), 388 | NSRange(location: 10, length: 0) 389 | ) 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /Tests/TwitterTextEditorTests/SchedulerTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SchedulerTest.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | @testable import TwitterTextEditor 11 | import XCTest 12 | 13 | private protocol Runner { 14 | func perform(_: @escaping () -> Void) 15 | } 16 | 17 | extension RunLoop: Runner { 18 | } 19 | 20 | extension DispatchQueue: Runner { 21 | func perform(_ block: @escaping () -> Void) { 22 | async(execute: block) 23 | } 24 | } 25 | 26 | private extension Collection { 27 | subscript(optional index: Index) -> Element? { 28 | indices.contains(index) ? self[index] : nil 29 | } 30 | } 31 | 32 | private extension Result { 33 | var success: Success? { 34 | switch self { 35 | case .success(let value): 36 | return value 37 | default: 38 | return nil 39 | } 40 | } 41 | 42 | var failure: Failure? { 43 | switch self { 44 | case .failure(let value): 45 | return value 46 | default: 47 | return nil 48 | } 49 | } 50 | } 51 | 52 | final class SchedulerTest: XCTestCase { 53 | private func wait(for runner: Runner) { 54 | let expectation = XCTestExpectation(description: String(describing: runner)) 55 | runner.perform { 56 | expectation.fulfill() 57 | } 58 | wait(for: [expectation], timeout: 3.0) 59 | } 60 | 61 | // MARK: - 62 | 63 | func testDebounceSchedulerShouldPerformScheduleOnce() { 64 | var performedCount = 0 65 | let scheduler = DebounceScheduler { 66 | performedCount += 1 67 | } 68 | 69 | scheduler.schedule() 70 | scheduler.schedule() 71 | 72 | XCTAssertEqual(performedCount, 0) 73 | 74 | wait(for: RunLoop.main) 75 | 76 | XCTAssertEqual(performedCount, 1) 77 | } 78 | 79 | func testDebounceSchedulerShouldPerform() { 80 | var performedCount = 0 81 | let scheduler = DebounceScheduler { 82 | performedCount += 1 83 | } 84 | 85 | scheduler.schedule() 86 | scheduler.perform() 87 | 88 | XCTAssertEqual(performedCount, 1) 89 | 90 | wait(for: RunLoop.main) 91 | 92 | XCTAssertEqual(performedCount, 1) 93 | } 94 | 95 | func testDebounceSchedulerShouldPerformScheduleAfterPerform() { 96 | var performedCount = 0 97 | let scheduler = DebounceScheduler { 98 | performedCount += 1 99 | } 100 | 101 | scheduler.schedule() 102 | scheduler.perform() 103 | scheduler.schedule() 104 | 105 | XCTAssertEqual(performedCount, 1) 106 | 107 | wait(for: RunLoop.main) 108 | 109 | XCTAssertEqual(performedCount, 2) 110 | } 111 | 112 | // MARK: - 113 | 114 | func testContentFilterSchedulerShouldPerformOnce() { 115 | var performedCount = 0 116 | let scheduler = ContentFilterScheduler { input, completion in 117 | performedCount += 1 118 | completion(.success(input)) 119 | } 120 | 121 | var results = [Result]() 122 | let completion = { (result: Result) -> Void in 123 | results.append(result) 124 | } 125 | 126 | scheduler.schedule("meow", completion: completion) 127 | scheduler.schedule("purr", completion: completion) 128 | 129 | XCTAssertEqual(performedCount, 0) 130 | XCTAssertEqual(results.count, 1) 131 | XCTAssertEqual(results[optional: 0]?.failure as? SchedulerError, .cancelled) 132 | 133 | wait(for: RunLoop.main) 134 | 135 | XCTAssertEqual(performedCount, 1) 136 | XCTAssertEqual(results.count, 2) 137 | XCTAssertEqual(results[optional: 1]?.success, "purr") 138 | } 139 | 140 | func testContentFilterSchedulerShouldUseCache() { 141 | var performedCount = 0 142 | let scheduler = ContentFilterScheduler { input, completion in 143 | performedCount += 1 144 | completion(.success(input)) 145 | } 146 | 147 | var results = [Result]() 148 | let completion = { (result: Result) -> Void in 149 | results.append(result) 150 | } 151 | 152 | scheduler.schedule("meow", completion: completion) 153 | 154 | wait(for: RunLoop.main) 155 | 156 | XCTAssertEqual(performedCount, 1) 157 | XCTAssertEqual(results.count, 1) 158 | XCTAssertEqual(results[optional: 0]?.success, "meow") 159 | 160 | scheduler.schedule("meow", completion: completion) 161 | 162 | wait(for: RunLoop.main) 163 | 164 | XCTAssertEqual(performedCount, 1) 165 | XCTAssertEqual(results.count, 2) 166 | XCTAssertEqual(results[optional: 1]?.success, "meow") 167 | } 168 | 169 | func testContentFilterSchedulerShouldPerformOnceAndUseCache() { 170 | var performedCount = 0 171 | let scheduler = ContentFilterScheduler { input, completion in 172 | performedCount += 1 173 | completion(.success(input)) 174 | } 175 | 176 | var results = [Result]() 177 | let completion = { (result: Result) -> Void in 178 | results.append(result) 179 | } 180 | 181 | scheduler.schedule("meow", completion: completion) 182 | 183 | wait(for: RunLoop.main) 184 | 185 | XCTAssertEqual(performedCount, 1) 186 | XCTAssertEqual(results.count, 1) 187 | XCTAssertEqual(results[optional: 0]?.success, "meow") 188 | 189 | // This schedule should be cancelled. 190 | scheduler.schedule("purr", completion: completion) 191 | // This schedule should use cache. 192 | scheduler.schedule("meow", completion: completion) 193 | 194 | XCTAssertEqual(performedCount, 1) 195 | XCTAssertEqual(results.count, 2) 196 | XCTAssertEqual(results[optional: 1]?.failure as? SchedulerError, .cancelled) 197 | 198 | wait(for: RunLoop.main) 199 | 200 | XCTAssertEqual(performedCount, 1) 201 | XCTAssertEqual(results.count, 3) 202 | XCTAssertEqual(results[optional: 2]?.success, "meow") 203 | } 204 | 205 | func testContentFilterSchedulerShouldFailWithNotLatest() { 206 | var performedCount = 0 207 | var delayedPerformedCount = 0 208 | 209 | let scheduler = ContentFilterScheduler { input, completion in 210 | performedCount += 1 211 | DispatchQueue.main.async { 212 | delayedPerformedCount += 1 213 | completion(.success(input)) 214 | } 215 | } 216 | 217 | var results = [Result]() 218 | let completion = { (result: Result) -> Void in 219 | results.append(result) 220 | } 221 | 222 | XCTAssertEqual(performedCount, 0) 223 | XCTAssertEqual(delayedPerformedCount, 0) 224 | 225 | scheduler.schedule("meow", completion: completion) 226 | 227 | wait(for: RunLoop.main) 228 | 229 | XCTAssertEqual(performedCount, 1) 230 | // `DispatchQueue.main.async` is executed always later than `RunLoop.main.perform`. 231 | // At this moment, previous schedule completion is not called. 232 | XCTAssertEqual(delayedPerformedCount, 0) 233 | 234 | scheduler.schedule("purr", completion: completion) 235 | 236 | // This will wait both first and second schedule completions because 237 | // `DispatchQueue.main` is a serial queue. 238 | wait(for: DispatchQueue.main) 239 | 240 | XCTAssertEqual(performedCount, 2) 241 | XCTAssertEqual(delayedPerformedCount, 2) 242 | XCTAssertEqual(results.count, 2) 243 | XCTAssertEqual(results[optional: 0]?.failure as? SchedulerError, .notLatest) 244 | XCTAssertEqual(results[optional: 1]?.success, "purr") 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /Tests/TwitterTextEditorTests/SequenceTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence.swift 3 | // TwitterTextEditor 4 | // 5 | // Copyright 2021 Twitter, Inc. 6 | // SPDX-License-Identifier: Apache-2.0 7 | // 8 | 9 | import Foundation 10 | @testable import TwitterTextEditor 11 | import XCTest 12 | 13 | final class SequenceTest: XCTestCase { 14 | func testForEachWithContinue() { 15 | let sequence = [1, 2, 3] 16 | 17 | let expectation = XCTestExpectation(description: "forEach") 18 | var results = [Int]() 19 | 20 | sequence.forEach(queue: DispatchQueue.global(), completion: { 21 | expectation.fulfill() 22 | }, { element, next in 23 | results.append(element) 24 | next(.continue) 25 | }) 26 | 27 | wait(for: [expectation], timeout: 3.0) 28 | 29 | XCTAssertEqual(results, sequence) 30 | } 31 | 32 | func testForEachWithBreak() { 33 | let sequence = [1, 2, 3] 34 | 35 | let expectation = XCTestExpectation(description: "forEach") 36 | var results = [Int]() 37 | 38 | sequence.forEach(queue: DispatchQueue.global(), completion: { 39 | expectation.fulfill() 40 | }, { element, next in 41 | results.append(element) 42 | next(.break) 43 | }) 44 | 45 | wait(for: [expectation], timeout: 3.0) 46 | 47 | XCTAssertEqual(results, [1]) 48 | } 49 | 50 | func testForEachWithoutCompletion() { 51 | let sequence = [1, 2, 3] 52 | 53 | let expectation = XCTestExpectation(description: "forEach") 54 | var results = [Int]() 55 | 56 | sequence.forEach(queue: DispatchQueue.global()) { element, next in 57 | results.append(element) 58 | if results.count < sequence.count { 59 | next(.continue) 60 | } else { 61 | expectation.fulfill() 62 | } 63 | } 64 | 65 | wait(for: [expectation], timeout: 3.0) 66 | 67 | XCTAssertEqual(results, sequence) 68 | } 69 | 70 | func testForEachWithAsyncBody() { 71 | let sequence = [1, 2, 3] 72 | 73 | let expectation = XCTestExpectation(description: "forEach") 74 | var results = [Int]() 75 | 76 | sequence.forEach(queue: DispatchQueue.global(), completion: { 77 | expectation.fulfill() 78 | }, { element, next in 79 | DispatchQueue.global().async { 80 | results.append(element) 81 | next(.continue) 82 | } 83 | }) 84 | 85 | wait(for: [expectation], timeout: 3.0) 86 | 87 | XCTAssertEqual(results, sequence) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /scripts/docserver.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Copyright 2021 Twitter, Inc. 5 | # SPDX-License-Identifier: Apache-2.0 6 | 7 | require 'rubygems' 8 | require 'listen' 9 | require 'optparse' 10 | require 'pathname' 11 | require 'webrick' 12 | 13 | class Watcher 14 | def initialize(watch_paths, &block) 15 | @watch_paths = Set.new 16 | @listen_paths = Set.new 17 | watch_paths.each do |watch_path| 18 | Pathname.glob(watch_path).each do |pathname| 19 | pathname = pathname.realpath 20 | 21 | @watch_paths.add(pathname.to_s) 22 | 23 | if pathname.directory? 24 | @listen_paths.add(pathname.to_s) 25 | else 26 | @listen_paths.add(pathname.dirname.to_s) 27 | end 28 | end 29 | end 30 | 31 | @block = block 32 | end 33 | 34 | def watch_paths 35 | @watch_paths.to_a 36 | end 37 | 38 | def start_non_blocking 39 | return if @listener 40 | 41 | options = { 42 | latency: 1.0, 43 | wait_for_delay: 1.0 44 | } 45 | @listener = Listen.to(*@listen_paths.to_a, options) do |modified, added, removed| 46 | changed = modified + added + removed 47 | 48 | changed.each do |path| 49 | pathname = Pathname.new(path).realpath 50 | 51 | loop do 52 | if @watch_paths.include?(pathname.to_s) 53 | @block.call 54 | break 55 | end 56 | 57 | break if pathname.root? 58 | 59 | pathname = pathname.parent 60 | end 61 | end 62 | end 63 | @listener.start 64 | end 65 | 66 | def stop 67 | return unless @listener 68 | 69 | @listener.stop 70 | @listener = nil 71 | end 72 | end 73 | 74 | class LocalWebServer 75 | attr_reader :port, :documents_path 76 | 77 | def initialize(documents_path, port) 78 | @documents_path = documents_path 79 | @port = port 80 | end 81 | 82 | def start_blocking 83 | server = WEBrick::HTTPServer.new( 84 | Port: port, 85 | DocumentRoot: documents_path, 86 | AccessLog: [] 87 | ) 88 | trap(:INT) do 89 | server.shutdown 90 | end 91 | server.start 92 | end 93 | end 94 | 95 | options = { 96 | documents_path: Dir.pwd, 97 | port: 3000 98 | } 99 | OptionParser.new do |opts| 100 | opts.banner = "Usage: #{$PROGRAM_NAME} [options] [watch path, ...]" 101 | 102 | opts.on('-d PATH', '--documents', 'Path to the documents.') do |value| 103 | options[:documents_path] = File.expand_path(value) 104 | end 105 | opts.on('-c COMMAND', '--command', 'A command to update the documents when watch path is modified') do |value| 106 | options[:update_command] = value 107 | end 108 | opts.on('-p PORT', '--port', Integer, 'A port to the web server listen.') do |value| 109 | options[:port] = value 110 | end 111 | end.parse! 112 | 113 | if options[:update_command] 114 | watcher = Watcher.new(ARGV) do 115 | puts 'Watched file change' 116 | system(options[:update_command]) 117 | end 118 | 119 | puts "Watching #{watcher.watch_paths.join(', ')} ..." 120 | watcher.start_non_blocking 121 | end 122 | 123 | local_web_server = LocalWebServer.new(options[:documents_path], options[:port]) 124 | 125 | puts "Running a web server at http://localhost:#{local_web_server.port} ..." 126 | local_web_server.start_blocking 127 | -------------------------------------------------------------------------------- /scripts/verify_documentation.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Copyright 2021 Twitter, Inc. 5 | # SPDX-License-Identifier: Apache-2.0 6 | 7 | require 'json' 8 | 9 | # See `/lib/jazzy/doc_builder.rb` for `undocumentd.json` structure. 10 | undocumented = JSON.parse(ARGF.read) 11 | warnings = undocumented['warnings'] || [] 12 | 13 | unless warnings.empty? 14 | warnings.each do |warning| 15 | warn "#{warning['file']}:#{warning['line']} #{warning['warning']}: #{warning['symbol']}" 16 | end 17 | exit 1 18 | end 19 | --------------------------------------------------------------------------------