├── .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 | 
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 | 
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