String {
56 | String(repeating: self, count: count)
57 | }
58 |
59 | func prefixedStringCount(needle: Character, ignoring: [Character] = []) -> Int {
60 | // If we're not prefixed with a tab, bail out with a count of 0
61 | guard self.isPrefixedWithTab else {
62 | return 0
63 | }
64 |
65 | var count = 0
66 | for char in self {
67 | // If this is a tab, add it to our count
68 | if char == needle {
69 | count += 1
70 | }
71 |
72 | // Oops, we found a character that isn't a tab, we're done here
73 | else {
74 | // We're only done here, if we have not been told to ignoring this character
75 | if ignoring.contains(char) == false {
76 | break
77 | }
78 | }
79 | }
80 |
81 | return count
82 | }
83 |
84 | }
85 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditor/RichEditor+Attachments.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditor+Attachments.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 7/12/20.
6 | //
7 |
8 | import AppKit
9 |
10 | public extension RichEditor {
11 |
12 | static var attachmentsDirectory: URL {
13 |
14 | // Get the documents directory
15 | guard let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
16 | fatalError()
17 | }
18 |
19 | // Create our own subdirectory within the documents directory
20 | let directoryURL = documentDirectory.appendingPathComponent("com.lumley.richeditor")
21 |
22 | // Create the directory, if it doesn't exist
23 | if FileManager.default.fileExists(atPath: directoryURL.path) == false {
24 | try! FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
25 | }
26 |
27 | return directoryURL
28 | }
29 |
30 | func promptUserForAttachments(windowForModal: NSWindow?) {
31 | let openPanel = NSOpenPanel()
32 | openPanel.allowsMultipleSelection = true
33 | openPanel.canChooseDirectories = false
34 | openPanel.canCreateDirectories = false
35 | openPanel.canChooseFiles = true
36 |
37 | if let window = windowForModal {
38 | openPanel.beginSheetModal(for: window, completionHandler: {(modalResponse) in
39 | if modalResponse == NSApplication.ModalResponse.OK {
40 | let selectedURLs = openPanel.urls
41 | self.insertAttachments(at: selectedURLs)
42 | }
43 | })
44 | }
45 | else {
46 | openPanel.begin(completionHandler: {(modalResponse) in
47 | if modalResponse == NSApplication.ModalResponse.OK {
48 | let selectedURLs = openPanel.urls
49 | self.insertAttachments(at: selectedURLs)
50 | }
51 | })
52 | }
53 | }
54 |
55 | func insertAttachments(at urls: [URL]) {
56 | self.textView.layoutManager?.defaultAttachmentScaling = .scaleProportionallyDown
57 |
58 | print("Inserting attachments at URLs: \(urls)")
59 |
60 | //Iterate over every URL and create a NSTextAttachment from it
61 | for url in urls {
62 |
63 | //Get a copy of the data and insert it into our attachments folder
64 | /*----------------------------------------------------------------*/
65 | let imageID = "\(UUID().uuidString).\(url.pathExtension)"
66 | let directoryURL = RichEditor.attachmentsDirectory
67 |
68 | let imageData = try! Data(contentsOf: url)
69 | let imageURL = directoryURL.appendingPathComponent(imageID)
70 |
71 | try! imageData.write(to: imageURL)
72 | /*----------------------------------------------------------------*/
73 |
74 | let attachment = imageURL.textAttachment
75 | let attachmentAttrStr = NSAttributedString(attachment: attachment)
76 |
77 | self.textView.textStorage?.append(attachmentAttrStr)
78 | }
79 | }
80 |
81 | }
82 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditor/RichEditor+Styling.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditor+Styling.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 7/12/20.
6 | //
7 |
8 | import AppKit
9 |
10 | extension RichEditor {
11 |
12 | /**
13 | Toggles the bold attribute for the selected text, or the future text if no text is selected
14 | */
15 | func toggleBold() {
16 | self.toggleTextView(with: .boldFontMask)
17 | }
18 |
19 | /**
20 | Toggles the italics attribute for the selected text, or the future text if no text is selected
21 | */
22 | func toggleItalic() {
23 | self.toggleTextView(with: .italicFontMask)
24 | }
25 |
26 | /**
27 | Toggles the underline attribute for the selected text, or the future text if no text is selected
28 | - parameter style: The style of the underline that we want
29 | */
30 | func toggleUnderline(_ style: NSUnderlineStyle) {
31 | self.toggleTextView(with: .underlineStyle, negativeValue: 0, positiveValue: style.rawValue)
32 | }
33 |
34 | /**
35 | Toggles the strikethrough attribute for the selected text, or the future text if no text is selected
36 | - parameter style: The style of the strikethrough that we want
37 | */
38 | func toggleStrikethrough(_ style: NSUnderlineStyle) {
39 | self.toggleTextView(with: .strikethroughStyle, negativeValue: 0, positiveValue: style.rawValue)
40 | }
41 |
42 | /**
43 | Applies the text colour for the selected text, or the future text if no text is selected
44 | */
45 | func apply(textColour: NSColor) {
46 | let colourAttr = [NSAttributedString.Key.foregroundColor: textColour]
47 | self.add(attributes: colourAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future)
48 | }
49 |
50 | /**
51 | Applies the highlight colour for the selected text, or the future text if no text is selected
52 | */
53 | func apply(highlightColour: NSColor) {
54 | let colourAttr = [NSAttributedString.Key.backgroundColor: highlightColour]
55 | self.add(attributes: colourAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future)
56 | }
57 |
58 | /**
59 | Applies the font for the selected text, or the future text if no text is selected
60 | */
61 | func apply(font: NSFont) {
62 | let fontAttr = [NSAttributedString.Key.font: font]
63 | self.add(attributes: fontAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future)
64 | }
65 |
66 | /**
67 | Applies the font for the selected text, or the future text if no text is selected
68 | */
69 | func apply(alignment: NSTextAlignment) {
70 | let paragraphStyle = NSMutableParagraphStyle()
71 | paragraphStyle.alignment = alignment
72 |
73 | let paragraphStyleAttr = [NSAttributedString.Key.paragraphStyle: paragraphStyle]
74 |
75 | // Apply this alignment to the paragraph the user is in, or the paragraph the user is highlighting
76 | let paragraphRange = self.textView.string.nsString.paragraphRange(for: self.textView.selectedRange())
77 |
78 | // Apply the text alignment to the current paragraph, and future paragraphs
79 | self.add(attributes: paragraphStyleAttr, textApplicationType: .range(range: paragraphRange))
80 | self.add(attributes: paragraphStyleAttr, textApplicationType: .future)
81 | }
82 |
83 | }
84 |
--------------------------------------------------------------------------------
/Tests/RichEditorTests/StringBulletPointsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StringBulletPointsTests.swift
3 | // RichEditor_Tests
4 | //
5 | // Created by William Lumley on 23/1/2022.
6 | // Copyright © 2022 CocoaPods. All rights reserved.
7 | //
8 |
9 | @testable import RichEditor
10 | import XCTest
11 |
12 | class StringBulletPointsTests: XCTestCase {
13 |
14 | override func setUpWithError() throws {
15 |
16 | }
17 |
18 | override func tearDownWithError() throws {
19 |
20 | }
21 |
22 | func testIsBulletPoint() {
23 | let testSubject1 = "\(RichEditor.bulletPointMarker) Hello there"
24 | let testSubject2 = "Hello there"
25 | let testSubject3 = "Hello \(RichEditor.bulletPointMarker) Hello there"
26 |
27 | XCTAssertTrue(testSubject1.isBulletPoint)
28 | XCTAssertFalse(testSubject2.isBulletPoint)
29 | XCTAssertFalse(testSubject3.isBulletPoint)
30 | }
31 |
32 | func testIsPrefixedWithTab() {
33 | let testSubject1 = "\tHello there"
34 | let testSubject2 = "Hello \t there \t"
35 | let testSubject3 = "Hello there"
36 |
37 | XCTAssertTrue(testSubject1.isPrefixedWithTab)
38 | XCTAssertFalse(testSubject2.isPrefixedWithTab)
39 | XCTAssertFalse(testSubject3.isPrefixedWithTab)
40 | }
41 |
42 | func testPrefixedStringCount() {
43 | let testSubject1 = "\tHello there"
44 | let testSubject2 = "\t\tHello there"
45 | let testSubject3 = "\t\t\t\tHello there\t\t"
46 | let testSubject4 = "Hello \t there \t"
47 | let testSubject5 = "Hello there"
48 | let testSubject6 = "\t\t=Hello there"
49 |
50 | XCTAssertEqual(testSubject1.prefixedStringCount(needle: "\t"), 1)
51 | XCTAssertEqual(testSubject2.prefixedStringCount(needle: "\t"), 2)
52 | XCTAssertEqual(testSubject3.prefixedStringCount(needle: "\t"), 4)
53 | XCTAssertEqual(testSubject4.prefixedStringCount(needle: "\t"), 0)
54 | XCTAssertEqual(testSubject5.prefixedStringCount(needle: "\t"), 0)
55 | XCTAssertEqual(testSubject6.prefixedStringCount(needle: "=", ignoring: [Character("\t")]), 1)
56 | }
57 |
58 | func testLines() {
59 | let testSubject = "Hello there\nHow now\nBrown Cow"
60 |
61 | XCTAssertEqual(testSubject.lines.count, 3)
62 |
63 | XCTAssertEqual(testSubject.lines[0], "Hello there")
64 | XCTAssertEqual(testSubject.lines[1], "How now")
65 | XCTAssertEqual(testSubject.lines[2], "Brown Cow")
66 | }
67 |
68 | func testRepeated() {
69 | let testSubject1 = "1"
70 | let testSubject2 = "2"
71 | let testSubject3 = "3"
72 | let testSubject4 = "4"
73 |
74 | XCTAssertEqual(testSubject1.repeated(1), "1")
75 | XCTAssertEqual(testSubject2.repeated(2), "22")
76 | XCTAssertEqual(testSubject3.repeated(3), "333")
77 | XCTAssertEqual(testSubject4.repeated(0), "")
78 | }
79 |
80 | func testRemoveFirst() {
81 | var testSubject1 = "Hello there Hello"
82 | var testSubject2 = "Hello Hello world"
83 | var testSubject3 = "Hello there foo"
84 | var testSubject4 = "\t\tTesting"
85 |
86 | testSubject1.removeFirst(needle: "Hello ")
87 | testSubject2.removeFirst(needle: "Hello ")
88 | testSubject3.removeFirst(needle: "foobar")
89 | testSubject4.removeFirst(needle: "\t")
90 |
91 | XCTAssertEqual(testSubject1, "there Hello")
92 | XCTAssertEqual(testSubject2, "Hello world")
93 | XCTAssertEqual(testSubject3, "Hello there foo")
94 | XCTAssertEqual(testSubject4, "\tTesting")
95 | }
96 |
97 | }
98 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditorToolbar/RichEditorToolbarButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditorToolbarButton.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 22/6/21.
6 | //
7 |
8 | import Cocoa
9 |
10 | class RichEditorToolbarButton: NSButton {
11 |
12 | // MARK: - Types
13 |
14 | struct ButtonImage {
15 | let unselectedLight: NSImage
16 | let unselectedDark: NSImage
17 | let selected: NSImage
18 | }
19 |
20 | // MARK: - Properties
21 |
22 | private var imageName: String
23 | private let buttonImage: ButtonImage
24 |
25 | var selected: Bool {
26 | didSet {
27 | self.loadImage()
28 | }
29 | }
30 |
31 | // MARK: - NSButton
32 |
33 | init(imageName: String, selectedTint: NSColor = .systemBlue) {
34 | self.imageName = imageName
35 | self.selected = false
36 |
37 | // All images provided to us are dark by default
38 | guard let darkImage = NSImage.podImage(rawName: imageName) else {
39 | fatalError("Failed to create image for RichEditorToolbarButton with image name: \(imageName)")
40 | }
41 |
42 | // Create a light version
43 | guard let lightImage = darkImage.inverted else {
44 | fatalError("Failed to invert image for RichEditorToolbarButton with image name: \(imageName)")
45 | }
46 |
47 | // Create a selected version
48 | guard let selectedImage = darkImage.createOverlay(color: selectedTint) else {
49 | fatalError("Failed to overlay for image for RichEditorToolbarButton with image name: \(imageName)")
50 | }
51 |
52 | self.buttonImage = ButtonImage(
53 | unselectedLight: lightImage,
54 | unselectedDark: darkImage,
55 | selected: selectedImage
56 | )
57 |
58 | super.init(frame: .zero)
59 |
60 | self.setup()
61 | self.loadImage()
62 | }
63 |
64 | required init?(coder: NSCoder) {
65 | fatalError("init(coder:) has not been implemented")
66 | }
67 |
68 | @available(macOS 10.14, *)
69 | override func viewDidChangeEffectiveAppearance()
70 | {
71 | super.viewDidChangeEffectiveAppearance()
72 | self.loadImage()
73 | }
74 |
75 | private func setup() {
76 | self.isBordered = false
77 | self.isTransparent = false
78 |
79 | self.wantsLayer = true
80 | self.layer?.backgroundColor = NSColor.clear.cgColor
81 | }
82 |
83 | internal func loadImage() {
84 |
85 | if #available(macOS 10.14, *) {
86 | // If we're selected, show the selected image
87 | if self.selected {
88 | self.image = self.buttonImage.selected
89 | }
90 |
91 | // We're not selected, show the light or dark mode image
92 | else if self.isDarkMode {
93 | self.image = self.buttonImage.unselectedDark
94 | }
95 |
96 | else if self.isDarkMode == false {
97 | self.image = self.buttonImage.unselectedLight
98 | }
99 | }
100 | else {
101 | // If we're selected, show the selected image
102 | if self.selected {
103 | self.image = self.buttonImage.selected
104 | }
105 |
106 | // We're not selected, show the light or dark mode image
107 | else {
108 | self.image = self.buttonImage.unselectedLight
109 | }
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/RichEditorExample/RichEditorExample/ViewControllers/PreviewViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PreviewViewController.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 13/7/21.
6 | // Copyright © 2021 William Lumley. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 | import RichEditor
11 | import WebKit
12 |
13 | class PreviewViewController: NSViewController {
14 |
15 | enum ContentType {
16 | case webView
17 | case textView
18 |
19 | var otherType: ContentType {
20 | switch self {
21 | case .textView: return .webView
22 | case .webView: return .textView
23 | }
24 | }
25 | }
26 |
27 | @IBOutlet weak var webView: WKWebView!
28 | @IBOutlet var textView: NSTextView!
29 |
30 | @IBOutlet weak var loadHtmlButton: NSButton!
31 | @IBOutlet weak var toggleContentTypeButton: NSButton!
32 |
33 | internal var richEditor: RichEditor?
34 |
35 | private var contentType: ContentType = .webView {
36 | didSet {
37 | switch self.contentType {
38 | case .textView:
39 | self.textView.isHidden = false
40 | self.webView.isHidden = true
41 |
42 | self.toggleContentTypeButton.title = "Display HTML"
43 | case .webView:
44 | self.textView.isHidden = true
45 | self.webView.isHidden = false
46 |
47 | self.toggleContentTypeButton.title = "Display Raw HTML"
48 | }
49 | }
50 | }
51 |
52 | // MARK: - NSViewController
53 |
54 | override func viewDidLoad() {
55 | super.viewDidLoad()
56 |
57 | self.contentType = .webView
58 |
59 | self.webView.navigationDelegate = self
60 | self.textView.isEditable = false
61 | }
62 |
63 | }
64 |
65 | // MARK: - Actions
66 |
67 | extension PreviewViewController {
68 |
69 | @IBAction func loadHtmlButtonClicked(_ sender: Any) {
70 | guard let richEditor = self.richEditor else {
71 | return
72 | }
73 |
74 | var htmlOpt: String?
75 |
76 | do {
77 | htmlOpt = try richEditor.html()
78 | }
79 | catch let error {
80 | print("Error creating HTML from NSAttributedString: \(error)")
81 | }
82 |
83 | guard var html = htmlOpt else {
84 | print("HTML from NSAttributedString was nil")
85 | return
86 | }
87 |
88 | html = html.replacingOccurrences(of: "\n", with: "")
89 |
90 | self.textView.string = html
91 |
92 | guard let htmlData = html.data(using: .utf8) else {
93 | return
94 | }
95 |
96 | let directoryURL = RichEditor.attachmentsDirectory
97 | let htmlFileURL = directoryURL.appendingPathComponent("index.html")
98 |
99 | try! htmlData.write(to: htmlFileURL)
100 |
101 | self.webView.loadFileURL(htmlFileURL, allowingReadAccessTo: directoryURL)
102 | }
103 |
104 | @IBAction func toggleContentTypeButtonClicked(_ sender: Any) {
105 | self.contentType = self.contentType.otherType
106 | }
107 |
108 | }
109 |
110 | // MARK: - WKNavigation Delegate
111 |
112 | extension PreviewViewController: WKNavigationDelegate {
113 |
114 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
115 | print("WKWebView did finish loading")
116 | }
117 |
118 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
119 | print("WKWebView Error: \(error)")
120 | }
121 |
122 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
123 | decisionHandler(.allow)
124 | }
125 |
126 | func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
127 | print("WKWebView ContentProcessDidTerminate")
128 | }
129 |
130 | }
131 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditorToolbar/RichEditorToolbar+UI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditorToolbar+UI.swift
3 | // macColorPicker
4 | //
5 | // Created by William Lumley on 6/6/21.
6 | //
7 |
8 | import AppKit
9 |
10 | internal extension RichEditorToolbar {
11 |
12 | func setupFontUI() {
13 | self.fontFamiliesPopUpButton.menu = NSMenu.fontsMenu()
14 | self.fontFamiliesPopUpButton.target = self
15 | self.fontFamiliesPopUpButton.action = #selector(fontFamiliesButtonClicked(_:))
16 |
17 | self.fontSizePopUpButton.menu = NSMenu.fontSizesMenu()
18 | self.fontSizePopUpButton.target = self
19 | self.fontSizePopUpButton.action = #selector(fontSizeButtonClicked(_:))
20 |
21 | self.contentStackView.addArrangedSubview(self.fontFamiliesPopUpButton)
22 | self.contentStackView.addArrangedSubview(self.fontSizePopUpButton)
23 |
24 | NSLayoutConstraint.activate([
25 | self.fontFamiliesPopUpButton.widthAnchor.constraint(equalToConstant: 125)
26 | ])
27 | }
28 |
29 | func setupWeightButtons() {
30 | self.contentStackView.addArrangedSubview(self.boldButton)
31 | self.contentStackView.addArrangedSubview(self.italicButton)
32 | self.contentStackView.addArrangedSubview(self.underlineButton)
33 |
34 | self.boldButton.target = self
35 | self.boldButton.action = #selector(boldButtonClicked(_:))
36 |
37 | self.italicButton.target = self
38 | self.italicButton.action = #selector(italicButtonClicked(_:))
39 |
40 | self.underlineButton.target = self
41 | self.underlineButton.action = #selector(underlineButtonClicked(_:))
42 | }
43 |
44 | func setupAlignButtons() {
45 | self.contentStackView.addArrangedSubview(self.alignLeftButton)
46 | self.contentStackView.addArrangedSubview(self.alignCentreButton)
47 | self.contentStackView.addArrangedSubview(self.alignRightButton)
48 | self.contentStackView.addArrangedSubview(self.alignJustifyButton)
49 |
50 | self.alignLeftButton.target = self
51 | self.alignLeftButton.action = #selector(alignLeftButtonClicked(_:))
52 |
53 | self.alignCentreButton.target = self
54 | self.alignCentreButton.action = #selector(alignCentreButtonClicked(_:))
55 |
56 | self.alignRightButton.target = self
57 | self.alignRightButton.action = #selector(alignRightButtonClicked(_:))
58 |
59 | self.alignJustifyButton.target = self
60 | self.alignJustifyButton.action = #selector(alignJustifyButtonClicked(_:))
61 | }
62 |
63 | func setupColorButtons() {
64 | self.contentStackView.addArrangedSubview(self.textColorButton)
65 | self.contentStackView.addArrangedSubview(self.highlightColorButton)
66 |
67 | self.textColorButton.delegate = self
68 | self.highlightColorButton.delegate = self
69 |
70 | NSLayoutConstraint.activate([
71 | self.textColorButton.widthAnchor.constraint(equalToConstant: 24),
72 | self.textColorButton.heightAnchor.constraint(equalToConstant: 24),
73 |
74 | self.highlightColorButton.widthAnchor.constraint(equalToConstant: 24),
75 | self.highlightColorButton.heightAnchor.constraint(equalToConstant: 24),
76 | ])
77 | }
78 |
79 | func setupCustomTextActionButtons() {
80 | self.contentStackView.addArrangedSubview(self.linkButton)
81 | self.contentStackView.addArrangedSubview(self.listButton)
82 | self.contentStackView.addArrangedSubview(self.strikethroughButton)
83 | self.contentStackView.addArrangedSubview(self.addImageButton)
84 |
85 | self.linkButton.target = self
86 | self.linkButton.action = #selector(linkButtonClicked(_:))
87 |
88 | self.listButton.target = self
89 | self.listButton.action = #selector(listButtonClicked(_:))
90 |
91 | self.strikethroughButton.target = self
92 | self.strikethroughButton.action = #selector(strikethroughButtonClicked(_:))
93 |
94 | self.addImageButton.target = self
95 | self.addImageButton.action = #selector(addImageButtonClicked(_:))
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Tests/RichEditorTests/NSAttributedStringConvenienceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSAttributedStringConvenienceTests.swift
3 | // RichEditor_Tests
4 | //
5 | // Created by William Lumley on 22/1/2022.
6 | // Copyright © 2022 William Lumley. All rights reserved.
7 | //
8 |
9 | import XCTest
10 |
11 | class NSAttributedStringConvenienceTests: XCTestCase {
12 |
13 | override func setUpWithError() throws {
14 | try super.setUpWithError()
15 | }
16 |
17 | override func tearDownWithError() throws {
18 |
19 | }
20 |
21 | func testAttributes() {
22 | let font = NSFont.boldSystemFont(ofSize: 12)
23 | let colour = NSColor.green.cgColor
24 |
25 | let testSubject = NSAttributedString(
26 | string: "Lorem Ipsum",
27 | attributes: [
28 | .font: font,
29 | .foregroundColor: colour
30 | ]
31 | )
32 |
33 | XCTAssertEqual(testSubject.attributes.count, 2)
34 |
35 | XCTAssertEqual(testSubject.attributes[.font] as! NSFont, font)
36 | XCTAssertEqual(testSubject.attributes[.foregroundColor] as! CGColor, colour)
37 | }
38 |
39 | func testAllFonts() {
40 | let boldFont = NSFont.boldSystemFont(ofSize: 12)
41 | let font = NSFont.systemFont(ofSize: 15)
42 |
43 | let testSubject = NSMutableAttributedString(
44 | string: "Lorem Ipsum",
45 | attributes: [
46 | .font: font,
47 | ]
48 | )
49 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4))
50 |
51 | XCTAssertEqual(testSubject.allFonts, [
52 | font,
53 | boldFont,
54 | ])
55 | }
56 |
57 | func testAllAlignments() {
58 | let leftAlignment = NSTextAlignment.left
59 | let rightAlignment = NSTextAlignment.right
60 |
61 | let paragraphStyle1 = NSMutableParagraphStyle()
62 | paragraphStyle1.alignment = leftAlignment
63 |
64 | let paragraphStyle2 = NSMutableParagraphStyle()
65 | paragraphStyle2.alignment = rightAlignment
66 |
67 | let testSubject = NSMutableAttributedString(
68 | string: "Lorem Ipsum",
69 | attributes: [
70 | .paragraphStyle: paragraphStyle1
71 | ]
72 | )
73 | testSubject.addAttribute(.paragraphStyle, value: paragraphStyle2, range: NSRange(location: 7, length: 4))
74 |
75 | XCTAssertEqual(testSubject.allAlignments, [
76 | leftAlignment,
77 | rightAlignment,
78 | ])
79 | }
80 |
81 | func testAllTextColours() {
82 | let green = NSColor.green
83 | let red = NSColor.red
84 |
85 | let testSubject = NSMutableAttributedString(
86 | string: "Lorem Ipsum",
87 | attributes: [
88 | .foregroundColor: green
89 | ]
90 | )
91 | testSubject.addAttribute(.foregroundColor, value: red, range: NSRange(location: 7, length: 4))
92 |
93 | XCTAssertEqual(testSubject.allTextColours, [
94 | green,
95 | red,
96 | ])
97 | }
98 |
99 | func testAllHighlightColours() {
100 | let green = NSColor.green
101 | let red = NSColor.red
102 |
103 | let testSubject = NSMutableAttributedString(
104 | string: "Lorem Ipsum",
105 | attributes: [
106 | .backgroundColor: green
107 | ]
108 | )
109 | testSubject.addAttribute(.backgroundColor, value: red, range: NSRange(location: 7, length: 4))
110 |
111 | XCTAssertEqual(testSubject.allHighlightColours, [
112 | green,
113 | red,
114 | ])
115 | }
116 |
117 | func testAllAttachments() {
118 | let attachment = NSTextAttachment(data: nil, ofType: "jpg")
119 |
120 | let testSubject = NSMutableAttributedString(string: "Lorem Ipsum")
121 | testSubject.addAttribute(.attachment, value: attachment, range: NSRange(location: 7, length: 4))
122 |
123 | XCTAssertEqual(testSubject.allAttachments, [
124 | attachment,
125 | ])
126 | }
127 |
128 | func testContainsFontTrait() {
129 | let font = NSFont.systemFont(ofSize: 12)
130 | let boldFont = NSFont.boldSystemFont(ofSize: 12)
131 |
132 | let testSubject = NSMutableAttributedString(
133 | string: "Lorem Ipsum",
134 | attributes: [
135 | .font: font,
136 | ]
137 | )
138 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4))
139 |
140 | XCTAssertTrue(testSubject.contains(trait: .boldFontMask))
141 | XCTAssertFalse(testSubject.contains(trait: .italicFontMask))
142 | }
143 |
144 | func testDoesNotContainFontTrait() {
145 | let font = NSFont.systemFont(ofSize: 12)
146 | let boldFont = NSFont.boldSystemFont(ofSize: 12)
147 |
148 | let testSubject = NSMutableAttributedString(
149 | string: "Lorem Ipsum",
150 | attributes: [
151 | .font: font,
152 | ]
153 | )
154 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4))
155 |
156 | XCTAssertTrue(testSubject.doesNotContain(trait: .italicFontMask))
157 | XCTAssertFalse(testSubject.doesNotContain(trait: .boldFontMask))
158 | }
159 |
160 | func testCheck() {
161 | let boldFont = NSFont.boldSystemFont(ofSize: 12)
162 |
163 | let testSubject = NSMutableAttributedString(
164 | string: "Lorem Ipsum",
165 | attributes: [
166 | .underlineStyle: NSNumber(value: NSUnderlineStyle.double.rawValue),
167 | ]
168 | )
169 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4))
170 |
171 | let underline = testSubject.check(attribute: .underlineStyle)
172 | let textColour = testSubject.check(attribute: .foregroundColor)
173 | let font = testSubject.check(attribute: .font)
174 |
175 | XCTAssertTrue(underline.atParts)
176 | XCTAssertFalse(underline.notAtParts)
177 |
178 | XCTAssertFalse(textColour.atParts)
179 | XCTAssertTrue(textColour.notAtParts)
180 |
181 | XCTAssertTrue(font.atParts)
182 | XCTAssertTrue(font.notAtParts)
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditor/RichEditor+Core.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditor+Core.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 7/12/20.
6 | //
7 |
8 | import Foundation
9 | import AppKit
10 |
11 | enum TextApplicationType {
12 | case selected
13 | case range(range: NSRange)
14 | case future
15 | }
16 |
17 | internal extension RichEditor {
18 |
19 | /**
20 | Toggles a certain font trait (bold, italic, etc) for the RichTextView.
21 | If text is highlighted, then only the highlighted text will have its font toggled. If no text is highlighted
22 | then all 'future' text will have the new traits.
23 |
24 | - parameter trait: This is the trait that you want the font to adhere to
25 | */
26 | func toggleTextView(with fontTrait: NSFontTraitMask) {
27 | //Get the current font, and make a 'new' version of it, with the traits provided to us
28 | let currentFont = self.currentFont
29 | var newFont = currentFont
30 |
31 | let textStyling = self.textStyling
32 | let textStylingTrait = textStyling.fontTraitFor(nsFontTraitMask: fontTrait)
33 |
34 | //print("\nOldFont: \(currentFont)")
35 | switch (textStylingTrait) {
36 | //If we're ONLY bold at the moment, let's make it unbold
37 | case .isTrait:
38 | newFont = NSFontManager.shared.convert(currentFont, toNotHaveTrait: fontTrait)
39 |
40 | //If we're ONLY unbold at the moment, let's make it bold
41 | case .isNotTrait:
42 | newFont = NSFontManager.shared.convert(currentFont, toHaveTrait: fontTrait)
43 |
44 | //If we're BOTH bold and unbold, we'll make it bold
45 | case .both:
46 | newFont = NSFontManager.shared.convert(currentFont, toHaveTrait: fontTrait)
47 | }
48 | //print("NewFont: \(newFont)\n")
49 |
50 | let updatedFontAttr = [NSAttributedString.Key.font: newFont]
51 | self.add(attributes: updatedFontAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future)
52 |
53 | self.richEditorDelegate?.fontStylingChanged(self.textStyling)
54 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling)
55 | }
56 |
57 | /**
58 | Toggles a certain NSAttributeString.Key (underline, strikethrough, etc) for the RichTextView.
59 | If text is highlighted, then only the highlighted text will have its NSAttributedText toggled.
60 | If no text is highlighted then all 'future' text will have the new attributes.
61 |
62 | - parameter attribute: This is the trait that you want the NSAttributedString to adhere to
63 | - parameter negativeValue: This is the 'off' value for the attribute. If `attribute` was
64 | NSUnderlineStyle, then the negativeValue would be NSUnderlineStyle.styleNone.rawValue
65 | - parameter positiveValue: This is the 'on' value for the attribute. If `attribute` was
66 | NSUnderlineStyle, then the positive would be NSUnderlineStyle.styleSingle.rawValue
67 | - parameter range: This is a property that allows users to override the default range that the attribute will be applied to, and to choose their own custom range.
68 | */
69 | func toggleTextView(with attribute: NSAttributedString.Key, negativeValue: Any, positiveValue: Any, range: NSRange? = nil) {
70 | let fontTrait = self.textStyling.trait(with: attribute)
71 |
72 | var newAttr = [NSAttributedString.Key: Any]()
73 | switch (fontTrait) {
74 | case .isTrait:
75 | newAttr = [attribute: negativeValue]
76 |
77 | case .isNotTrait:
78 | newAttr = [attribute: positiveValue]
79 |
80 | case .both:
81 | newAttr = [attribute: positiveValue]
82 | }
83 |
84 | // If the user has highlighted text, apply it to that range
85 | // If the user has not highlighted text, apply it to all future text
86 | // If the user has provided a range, use that instead
87 | var type: TextApplicationType = self.textView.hasSelectedText ? .selected : .future
88 | if let range = range {
89 | type = .range(range: range)
90 | }
91 |
92 | self.add(attributes: newAttr, textApplicationType: type)
93 |
94 | self.richEditorDelegate?.fontStylingChanged(self.textStyling)
95 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling)
96 | }
97 |
98 | /**
99 | Adds the provided NSAttributedString.Key attributes to the TextView. The attributes will either
100 | be applied to the text that is selected, or to all 'future' text. This is dependant on the
101 | selected argument.
102 | - parameter attributes: The attributes that we wish to apply to our NSTextView
103 | - parameter textApplicationType: Determines how the effects of the NSAttributedString.Key will be applied to our TextViews string
104 | */
105 | func add(attributes: [NSAttributedString.Key: Any], textApplicationType: TextApplicationType) {
106 |
107 | switch textApplicationType {
108 | case .selected:
109 | let selectedRange = self.textView.selectedRange()
110 |
111 | self.textStorage.addAttributes(attributes, range: selectedRange)
112 |
113 | //Create an attributed string out of ONLY the highlighted text
114 | guard let attr = self.textView.attributedSubstring(forProposedRange: selectedRange, actualRange: nil) else {
115 | return
116 | }
117 |
118 | //Ensure the UI is updated with the new TextStyling state's
119 | self.selectedTextFontStyling = TextStyling(attributedString: attr)
120 |
121 | case .range(let range):
122 | self.textStorage.addAttributes(attributes, range: range)
123 |
124 | //Create an attributed string out of ONLY the highlighted text
125 | guard let attr = self.textView.attributedSubstring(forProposedRange: range, actualRange: nil) else {
126 | return
127 | }
128 |
129 | //Ensure the UI is updated with the new TextStyling state's
130 | self.selectedTextFontStyling = TextStyling(attributedString: attr)
131 |
132 | case .future:
133 | //Get the existing TypingAttributes, and merge it into our new attributes dictionary
134 | var typingAttributes = self.textView.typingAttributes
135 | //print("Old TypingAttributes: \(typingAttributes)")
136 |
137 | typingAttributes.merge(newDict: attributes)
138 | //print("New TypingAttributes: \(typingAttributes)\n")
139 |
140 | self.textView.typingAttributes = typingAttributes
141 |
142 | }
143 |
144 | self.richEditorDelegate?.fontStylingChanged(self.textStyling)
145 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling)
146 | }
147 |
148 | }
149 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # RichEditor
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | RichEditor is a WYSIWYG editor written in pure Swift. RichEditor is a direct subclass of `NSTextView` so you'll feel perfectly comfortable using it.
17 |
18 | If you are developing a macOS application that uses an `NSTextView` and you want your users to be able to format their text, you are forced to use the `usesInspectorBar` property, which adds a formatting toolbar to your `NSTextView`.
19 |
20 | However if you want to modify the UI or modify functionality at all, you'll soon find a dead-end. This is where `RichEditor` comes in.
21 |
22 | Just drag `RichEditor` into your UI, and you can use RichEditors functionality to easily programatically control which parts of your text are formatted and how. From bolding and underlining to text alignment and text colour, you have control over your text view. You can create your own UI the way you want and connect your UI to the `RichEditor` functionality.
23 |
24 | However if you want to use a template UI, RichEditor has one last trick up its sleeve for you. Simply call the `configureToolbar()` from your instance of `RichEditor` and you will have our pre-made toolbar ready to go!
25 |
26 | RichEditor also handles the difficult logic of handling text formatting when a user has already highlighted a piece of text, or when you want to export the text with HTML formatting.
27 |
28 | RichEditor allows you to control:
29 | - [x] Bolding
30 | - [x] Italics
31 | - [x] Underlining
32 | - [x] Font Selection
33 | - [x] Font Size Selection
34 | - [x] Text Alignment (Left, Centre, Right, Justified)
35 | - [x] Text Colour
36 | - [x] Text Highlight Colour
37 | - [x] Link Insertion
38 | - [x] Bullet Points
39 | - [x] Text Strikethrough
40 | - [x] Attachment Insertion
41 |
42 | To do:
43 | - [ ] Implement better bullet point formatting
44 |
45 | You can see the provided `RichEditorToolbar` in action, as well as the export functionality in the screenshots below.
46 | These screenshots are taken from the Example project that you can use in the repository in the Example directory.
47 |
48 |
49 |
50 |
51 |
52 | ## Usage
53 |
54 | RichEditor is a direct subclass of `NSTextView` and as such, you can drag `NSTextView` into your storyboard and subclass it there, or you can directly instantiate `RichEditor` directly in your code using the initialisers from `NSTextView`.
55 |
56 | ### Exporting
57 |
58 | RichEditor allows you to export the content of the `RichEditor` as a HTML. You can do this as follows.
59 | ```swift
60 | let html = try richEditor.html()
61 | ```
62 |
63 | `html()` returns an optional `String` type, and will `throw` in the event of an error.
64 |
65 | ### Format Types
66 |
67 | Below is a walkthrough of formatting that RichEditor allows you to use.
68 |
69 | ----
70 |
71 | **Bold**
72 |
73 | `richEditor.toggleBold()`
74 |
75 | ----
76 |
77 | **Italic**
78 |
79 | `richEditor.toggleItalic()`
80 |
81 | ----
82 |
83 | **Underline**
84 |
85 | `richEditor.toggleUnderline(.single)`
86 |
87 | `toggleUnderline(_)` takes `NSUnderlineStyle` as an argument, so you can specify which underline style should be applied.
88 |
89 | ----
90 |
91 | **Strikethrough**
92 |
93 | `richEditor.toggleStrikethrough(.single)`
94 |
95 | `toggleStrikethrough(_)` takes `NSUnderlineStyle` as an argument, so you can specify which underline style should be applied with your strikethrough.
96 |
97 | ----
98 |
99 | **Text Colour**
100 |
101 | `richEditor.applyColour(textColour: .green)`
102 |
103 | `applyColour(textColour:)` takes `NSColor` as an argument, so you can specify which colour should be applied.
104 |
105 | ----
106 |
107 | **Text Highlighy Colour**
108 |
109 | `richEditor.applyColour(highlightColour: .green)`
110 |
111 | `applyColour(highlightColour:)` takes `NSColor` as an argument, so you can specify which colour should be applied.
112 |
113 | ----
114 |
115 | **Font**
116 |
117 | `richEditor.apply(font: .systemFont(ofSize: 12))`
118 |
119 | `applyColour(font:)` takes `NSFont` as an argument, so you can specify which font should be applied.
120 |
121 | ----
122 |
123 | **Text Alignment**
124 |
125 | `richEditor.apply(alignment: .left)`
126 |
127 | `applyColour(alignment:)` takes `NSTextAlignment` as an argument, so you can specify which alignment should be applied.
128 |
129 | ----
130 |
131 | **Links**
132 |
133 | `richEditor.insert(link: url, with: name)`
134 |
135 | `insert(link:, with:, at:)` takes a `URL` as an argument, so you can specify which alignment should be applied.
136 |
137 | A `String` is also taken for how you want this link to appear to the user.
138 |
139 | An optional `Int` argument can also be supplied which indicates what index of the `NSTextView`s string the link should be insert at. If nil, the link will be appended to the end of the string.
140 |
141 | ----
142 |
143 | ## Example Project
144 |
145 | To run the example project, clone the repo, and open the example Xcode Project in RichEditorExample.
146 |
147 | ## Requirements
148 |
149 | RichEditor supports iOS 10.0 and above & macOS 10.10 and above.
150 |
151 | ## Installation
152 |
153 | ### Swift Package Manager
154 | RichEditor is available through [Swift Package Manager](https://github.com/apple/swift-package-manager).
155 | To install it, simply add the dependency to your Package.Swift file:
156 |
157 | ```swift
158 | ...
159 | dependencies: [
160 | .package(url: "https://github.com/will-lumley/RichEditor.git", from: "1.2.0"),
161 | ],
162 | targets: [
163 | .target( name: "YourTarget", dependencies: ["RichEditor"]),
164 | ]
165 | ...
166 | ```
167 |
168 | ### Cocoapods and Carthage
169 | RichEditor was previously available through CocoaPods and Carthage, however making the library available to all three Cocoapods,
170 | Carthage, and SPM (and functional to all three) was becoming troublesome. This, combined with the fact that SPM has seen a serious
171 | up-tick in adoption & functionality, has led me to remove support for CocoaPods and Carthage.
172 |
173 | ## Author
174 |
175 | [William Lumley](https://lumley.io/), will@lumley.io
176 |
177 | ## License
178 |
179 | RichEditor is available under the MIT license. See the LICENSE file for more info.
180 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Extensions/NSTextView+Convenience.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSTextView+Generics.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 20/3/18.
6 | // Copyright © 2018 William Lumley. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AppKit
11 |
12 | extension NSTextView {
13 |
14 | struct LineInfo {
15 | let lineNumber: Int
16 | let lineRange: NSRange
17 | let lineString: String
18 | let caretLocation: Int
19 | }
20 |
21 | /// Determines if the user has selected (ie. highlighted) any text
22 | var hasSelectedText: Bool {
23 | self.selectedRange().length > 0
24 | }
25 |
26 | /// The location of our caret within the textview
27 | var caretLocation: Int {
28 | self.selectedRange().location
29 | }
30 |
31 | /**
32 | Calculates which line number (using a 0 based index) our caret is on, the range of this line (in comparison to the whole string), and the string that makes up that line of text.
33 | Will return nil if there is no caret present, and a portion of text is highlighted instead.
34 |
35 | A pain point of this function is that it cannot return the current line number when it's found, but rather
36 | has to wait for every single line to be iterated through first. This is because the enumerateSubstrings() function
37 | on the String is not an actual loop, and as such we cannot return or break within it.
38 |
39 | - returns: The line number that the caret is on, the range of our line, and the string that makes up that line of text
40 | */
41 | var currentLine: LineInfo {
42 | //The line number that we're currently iterating on
43 | var lineNumber = 0
44 |
45 | //The line number & line of text that we believe the caret to be on
46 | var selectedLineNumber = 0
47 | var selectedLineRange = NSRange(location: 0, length: 0)
48 | var selectedLineOfText = ""
49 | var caretLocationInLine = 0
50 |
51 | var foundSelectedLine = false
52 |
53 | //Iterate over every line in our TextView
54 | self.string.enumerateSubstrings(in: self.string.startIndex..= startOfLine && self.caretLocation <= endOfLine {
64 | // MARK the line number
65 | selectedLineNumber = lineNumber
66 | selectedLineOfText = substring ?? ""
67 | selectedLineRange = range
68 | caretLocationInLine = self.caretLocation - startOfLine
69 |
70 | foundSelectedLine = true
71 | }
72 |
73 | lineNumber += 1
74 | }
75 |
76 | //If we're not at the starting point, and we didn't find a current line, then we're at the end of our TextView
77 | if self.caretLocation > 0 && !foundSelectedLine {
78 | selectedLineNumber = lineNumber
79 | selectedLineOfText = ""
80 | selectedLineRange = NSRange(location: self.caretLocation, length: 0)
81 | }
82 |
83 | return LineInfo(lineNumber: selectedLineNumber, lineRange: selectedLineRange, lineString: selectedLineOfText, caretLocation: caretLocationInLine)
84 | }
85 |
86 | /**
87 | Replaces the current NSString/NSAttributedString that is currently within
88 | the NSTextView and replaces it with the provided HTML string.
89 | This HTML string is converted into a UTF8 encoded piece of data at first,
90 | and then converted to an NSAttributedString using native cocoa functions
91 | - parameter html: The HTML we wish to populate our NSTextView with
92 | - returns: A boolean value indicative of if the conversion and setting of
93 | the HTML string was successful
94 | */
95 | func set(html: String) -> Bool {
96 | guard let htmlData = html.data(using: .utf8) else {
97 | print("Error creating NSAttributedString, HTML data is nil.")
98 | return false
99 | }
100 |
101 | let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtfd]
102 | guard let attrFromHTML = NSAttributedString(html: htmlData, options: options, documentAttributes: nil) else {
103 | print("Error creating NSAttributedString, NSAttributedString from HTML is nil.")
104 | return false
105 | }
106 |
107 | //We've created the NSAttributedString from the HTML, let's apply it
108 | return self.set(attributedString: attrFromHTML)
109 | }
110 |
111 | /**
112 | Replaces the current NSString/NSAttributedString that is currently within
113 | the NSTextView and replaces it with the provided NSAttributedString
114 | - parameter attributedString: The NSAttributedString we wish to populate our NSTextView with
115 | - returns: A boolean value indicative of if the setting of the NSAttributedString was successful
116 | */
117 | @discardableResult
118 | func set(attributedString: NSAttributedString) -> Bool {
119 | guard let textStorage = self.textStorage else {
120 | print("Error setting NSAttributedString, TextStorage is nil.")
121 | return false
122 | }
123 |
124 | let fullRange = self.string.fullRange
125 | textStorage.replaceCharacters(in: fullRange, with: attributedString)
126 |
127 | return true
128 | }
129 |
130 | func iterateThroughAllAttachments() {
131 | let attachments = self.attributedString().allAttachments
132 | for attachment in attachments {
133 | guard let fileWrapper = attachment.fileWrapper else {
134 | continue
135 | }
136 |
137 | if !fileWrapper.isRegularFile {
138 | continue
139 | }
140 |
141 | guard let fileData = fileWrapper.regularFileContents else { continue }
142 | let fileName = fileWrapper.filename
143 | let fileAttr = fileWrapper.fileAttributes
144 | let fileIcon = fileWrapper.icon
145 |
146 | print("FileAttributes: \(fileAttr)")
147 | print("FileData: \(fileData)")
148 | print("FileName: \(fileName ?? "NoName")")
149 | print("FileIcon: \(String(describing: fileIcon))")
150 |
151 | print("")
152 | }
153 | }
154 |
155 | func append(_ string: String) {
156 | let textViewText = NSMutableAttributedString(attributedString: self.attributedString())
157 | textViewText.append(NSAttributedString(string: string, attributes: self.typingAttributes))
158 |
159 | self.set(attributedString: textViewText)
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditor/RichEditor+BulletPoints.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditor+BulletPoints.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 7/12/20.
6 | //
7 |
8 | import AppKit
9 |
10 | public extension RichEditor {
11 |
12 | func startBulletPoints() {
13 | let currentLine = self.textView.currentLine
14 |
15 | // Get the string that makes up our current string, and find out where it sits in our TextView
16 | let currentLineStr = currentLine.lineString
17 | let currentLineRange = currentLine.lineRange
18 |
19 | // If our current line already has a bullet point, remove it
20 | if currentLineStr.isBulletPoint {
21 | //Get the line in our TextView that our caret is on, and remove our bulletpoint
22 | var noBulletPointStr = currentLineStr.replacingOccurrences(of: "\(RichEditor.bulletPointMarker) ", with: "")
23 | noBulletPointStr = currentLineStr.replacingOccurrences(of: RichEditor.bulletPointMarker, with: "")
24 |
25 | self.textView.replaceCharacters(in: currentLineRange, with: noBulletPointStr)
26 | }
27 | // If our current line doesn't already have a bullet point appended to it, prepend one
28 | else {
29 | // Get the line in our TextView that our caret is on, and prepend a bulletpoint to it
30 | let bulletPointStr = "\(RichEditor.bulletPointMarker) \(currentLineStr)"
31 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr)
32 | }
33 | }
34 |
35 | }
36 |
37 | extension RichEditor: NSTextViewDelegate {
38 |
39 | public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool {
40 |
41 | // Handle:
42 | // 1. If a line with *just* a bullet point exists and backspace is pressed, delete 1 tab
43 |
44 | // Get all the lines of the text
45 | // Get the line of text that we're on
46 |
47 | guard let newString = replacementString else { return true }
48 |
49 | let currentLine = textView.currentLine
50 |
51 | // If the line we're currently on is NOT prefixed with a bullet point, bail out
52 | let currentLineStr = currentLine.lineString
53 | if currentLineStr.isBulletPoint == false {
54 | // We have decided we don't want to make any artificial changes, let the literal changes go through
55 | return true
56 | }
57 |
58 | let currentLineRange = currentLine.lineRange
59 |
60 | // If the user just hit enter/newline
61 | if newString == "\n" {
62 | // The line we're currently on is prefixed with a bullet point, append a bullet point to the next line
63 |
64 | // If our current line is just an empty bullet point line, remove the bullet point and turn it into a regular line
65 | if currentLineStr == RichEditor.bulletPointMarker {
66 | self.textView.replaceCharacters(in: currentLineRange, with: "")
67 | }
68 |
69 | // If our current line is a full bullet point line, append a brand spanking new bullet point line below our current line for our user
70 | else {
71 |
72 | // We want to make sure that our newline has the same amount of starting tabs as our current line
73 | var prependedTabs = ""
74 | if currentLineStr.isPrefixedWithTab {
75 | prependedTabs = "\t".repeated(currentLineStr.prefixedStringCount(needle: "\t"))
76 | }
77 |
78 | let bulletPointStr = "\(currentLineStr)\n\(prependedTabs)\(RichEditor.bulletPointMarker)"
79 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr)
80 | }
81 |
82 | // We've made the artificial changes to the string, don't let the literal change go through
83 | return false
84 | }
85 |
86 | // If the user just hit the tab button
87 | else if newString == "\t" {
88 | let bulletPointStr = "\t\(currentLineStr)"
89 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr)
90 |
91 | // We've made the artificial changes to the string, don't let the literal change go through
92 | return false
93 | }
94 |
95 | // If the user just hit the backspace button
96 | else if newString.isBackspace {
97 | // If our line has any tabs prepended to it, delete one of them
98 | if currentLineStr.isPrefixedWithTab {
99 |
100 | // Get the CaretLocation of our caret relative to this current line
101 | var caretLocation = currentLine.caretLocation
102 |
103 | // Calculate how many bullet point & tabs exist in the start of this line
104 | let prefixTabCount = currentLineStr.prefixedStringCount(needle: "\t")
105 | let prefixBulletPointCount = currentLineStr.prefixedStringCount(needle: "•", ignoring: ["\t"])
106 |
107 | // Remove the bullet point & tab count from our location, as we don't consider them actual characters
108 | caretLocation = caretLocation - (prefixTabCount + prefixBulletPointCount)
109 |
110 | // Remove the extra space that sits after the bullet point
111 | caretLocation -= 1
112 |
113 | // If our caret is at the start (barring any tabs and bullet point markers) of our line
114 | if caretLocation == 0 {
115 | var bulletPointStr = String(currentLineStr)
116 | bulletPointStr.removeFirst(needle: "\t")
117 |
118 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr)
119 |
120 | // We've made the artificial changes to the string, don't let the literal change go through
121 | return false
122 | }
123 | }
124 | }
125 |
126 | // We have decided we don't want to make any artificial changes, let the literal changes go through// We have decided we don't want to make any artificial changes, let the literal changes go through
127 | return true
128 | }
129 |
130 | /*
131 | func textView(_ textView: NSTextView, urlForContentsOf textAttachment: NSTextAttachment, at charIndex: Int) -> URL?
132 | {
133 | print("TextAttachment: \(textAttachment)")
134 | print("CharIndex: \(charIndex)")
135 |
136 | let fileWrapper = textAttachment.fileWrapper!
137 | //let metaFileWrappers = fileWrapper.fileWrappers
138 | let data = fileWrapper.regularFileContents ?? Data()
139 |
140 | //print("FileWrapper.fileWrappers: \(String(describing: metaFileWrappers))")
141 | print("Data: \(data)")
142 |
143 | print("")
144 | return nil
145 | }
146 | */
147 |
148 | public func textViewDidChangeSelection(_ notification: Notification) {
149 | let selectedRange = self.textView.selectedRange()
150 | let isSelected = selectedRange.length > 0
151 |
152 | if !isSelected {
153 | self.selectedTextFontStyling = nil
154 | return
155 | }
156 |
157 | //Create an attributed string out of ONLY the highlighted text
158 | guard let attr = self.textView.attributedSubstring(forProposedRange: selectedRange, actualRange: nil) else {
159 | return
160 | }
161 |
162 | self.selectedTextFontStyling = TextStyling(attributedString: attr)
163 | }
164 |
165 | }
166 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/TextStyling.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TextStyling.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 20/3/18.
6 | // Copyright © 2018 William Lumley. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AppKit
11 |
12 | public struct TextStyling {
13 |
14 | public private(set) var isBold : Bool
15 | public private(set) var isUnbold: Bool
16 |
17 | public private(set) var isItalic : Bool
18 | public private(set) var isUnitalic: Bool
19 |
20 | public private(set) var isUnderline : Bool
21 | public private(set) var isUnunderline: Bool
22 |
23 | public private(set) var isStrikethrough : Bool
24 | public private(set) var isUnstrikethrough: Bool
25 |
26 | public private(set) var fonts: [NSFont]
27 | public private(set) var alignments: [NSTextAlignment]
28 |
29 | public private(set) var textColours : [NSColor]
30 | public private(set) var highlightColours: [NSColor]
31 |
32 | public var boldTrait: TextStyling.Trait {
33 | //If we're ONLY bold
34 | if self.isBold && !self.isUnbold {
35 | return .isTrait
36 | }
37 |
38 | //If we're ONLY unbold
39 | else if !self.isBold && self.isUnbold {
40 | return .isNotTrait
41 | }
42 |
43 | //If we're BOTH bold and unbold
44 | else if self.isBold && self.isUnbold {
45 | return .both
46 | }
47 |
48 | fatalError("Failed to reach conclusion for BoldTrait, for TextStyling: \(self)")
49 | }
50 |
51 | public var italicsTrait: TextStyling.Trait {
52 | //If we're ONLY italic
53 | if self.isItalic && !self.isUnitalic {
54 | return .isTrait
55 | }
56 |
57 | //If we're ONLY unitalic
58 | else if !self.isItalic && self.isUnitalic {
59 | return .isNotTrait
60 | }
61 |
62 | //If we're BOTH italic and unitalic
63 | else if self.isItalic && self.isUnitalic {
64 | return .both
65 | }
66 |
67 | fatalError("Failed to reach conclusion for ItalicTrait, for TextStyling: \(self)")
68 | }
69 |
70 | public var underlineTrait: TextStyling.Trait {
71 | //If we're ONLY underline
72 | if self.isUnderline && !self.isUnunderline {
73 | return .isTrait
74 | }
75 |
76 | //If we're ONLY un-underline
77 | else if !self.isUnderline && self.isUnunderline {
78 | return .isNotTrait
79 | }
80 |
81 | //If we're BOTH underline and un-underline
82 | else if self.isUnderline && self.isUnunderline {
83 | return .both
84 | }
85 |
86 | fatalError("Failed to reach conclusion for UnderlineTrait, for TextStyling: \(self)")
87 | }
88 |
89 | public var strikethroughTrait: TextStyling.Trait {
90 | //If we're ONLY strikethrough
91 | if self.isStrikethrough && !self.isUnstrikethrough {
92 | return .isTrait
93 | }
94 |
95 | //If we're ONLY un-strikethrough
96 | else if !self.isStrikethrough && self.isUnstrikethrough {
97 | return .isNotTrait
98 | }
99 |
100 | //If we're BOTH strikethrough and un-strikethrough
101 | else if self.isStrikethrough && self.isUnstrikethrough {
102 | return .both
103 | }
104 |
105 | fatalError("Failed to reach conclusion for StrikethroughTrait, for TextStyling: \(self)")
106 | }
107 |
108 | // MARK: - TextStyling
109 |
110 | init(attributedString: NSAttributedString) {
111 | self.textColours = attributedString.allTextColours
112 |
113 | self.isBold = attributedString.contains(trait: .boldFontMask)
114 | self.isUnbold = attributedString.doesNotContain(trait: .boldFontMask)
115 |
116 | self.isItalic = attributedString.contains(trait: .italicFontMask)
117 | self.isUnitalic = attributedString.doesNotContain(trait: .italicFontMask)
118 |
119 | let underlineQualities = attributedString.check(attribute: NSAttributedString.Key.underlineStyle)
120 | self.isUnderline = underlineQualities.atParts
121 | self.isUnunderline = underlineQualities.notAtParts
122 |
123 | let strikethroughQualities = attributedString.check(attribute: NSAttributedString.Key.strikethroughStyle)
124 | self.isStrikethrough = strikethroughQualities.atParts
125 | self.isUnstrikethrough = strikethroughQualities.notAtParts
126 |
127 | self.textColours = attributedString.allTextColours
128 | self.highlightColours = attributedString.allHighlightColours
129 |
130 | self.fonts = attributedString.allFonts
131 | self.alignments = attributedString.allAlignments
132 | }
133 |
134 | init(typingAttributes: [NSAttributedString.Key: Any]) {
135 | let font = typingAttributes[NSAttributedString.Key.font] as! NSFont
136 |
137 | self.isBold = font.contains(trait: .boldFontMask)
138 | self.isUnbold = !self.isBold
139 |
140 | self.isItalic = font.contains(trait: .italicFontMask)
141 | self.isUnitalic = !self.isItalic
142 |
143 | self.isUnderline = typingAttributes.check(attribute: NSAttributedString.Key.underlineStyle) {(rawAttr) -> Bool in
144 | return rawAttr == 0
145 | }
146 | self.isUnunderline = !self.isUnderline
147 |
148 | self.isStrikethrough = typingAttributes.check(attribute: NSAttributedString.Key.strikethroughStyle) {(rawAttr) -> Bool in
149 | return rawAttr == 0
150 | }
151 | self.isUnstrikethrough = !self.isStrikethrough
152 |
153 | self.textColours = []
154 | self.highlightColours = []
155 |
156 | self.fonts = []
157 | self.alignments = []
158 |
159 | if let textColour = typingAttributes[NSAttributedString.Key.foregroundColor] as? NSColor {
160 | self.textColours = [textColour]
161 | }
162 |
163 | if let highlightColour = typingAttributes[NSAttributedString.Key.backgroundColor] as? NSColor {
164 | self.highlightColours = [highlightColour]
165 | }
166 |
167 | if let font = typingAttributes[NSAttributedString.Key.font] as? NSFont {
168 | self.fonts = [font]
169 | }
170 |
171 | if let paragraphStyle = typingAttributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle {
172 | self.alignments = [paragraphStyle.alignment]
173 | }
174 |
175 | //print("Typing Attributes: \(typingAttributes)")
176 | }
177 |
178 | // MARK: - Functions
179 |
180 | /**
181 | Given an NSFontTraitMask, matches the correlating Trait enum that correlates with the provided argument
182 | - parameter nsFontTraitMask: The NSFontTraitMask that we need to match to a Trait enum
183 | - returns: A Trait enum that will correlate with the NSFontTraitMask
184 | */
185 | public func fontTraitFor(nsFontTraitMask: NSFontTraitMask) -> TextStyling.Trait {
186 | switch (nsFontTraitMask) {
187 | case .boldFontMask:
188 | return self.boldTrait
189 | case .italicFontMask:
190 | return self.italicsTrait
191 | default:
192 | fatalError("Failed to reach conclusion for determining correlating Trait and NSFontTraitMask. NSFontTraitMask: \(nsFontTraitMask)")
193 | }
194 | }
195 |
196 | public func trait(with key: NSAttributedString.Key) -> TextStyling.Trait {
197 | switch (key) {
198 | case .strikethroughStyle:
199 | return self.strikethroughTrait
200 |
201 | case .underlineStyle:
202 | return self.underlineTrait
203 |
204 | default:
205 | fatalError("NSAttributedString.Key has not been accounted for in Trait determination: \(key).")
206 | }
207 | }
208 |
209 | }
210 |
211 | public extension TextStyling {
212 |
213 | enum Trait {
214 | case isTrait
215 | case isNotTrait
216 | case both
217 | }
218 |
219 | }
220 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Extensions/NSAttributedString+Convenience.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSAttributedString+Generics.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 20/3/18.
6 | // Copyright © 2018 William Lumley. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AppKit
11 |
12 | public extension NSAttributedString {
13 |
14 | /**
15 | Determines the attributes for the whole complete NSAttributedString
16 | - returns: The attributes, in the form of a dictionary, for the whole NSAttributedString
17 | */
18 | var attributes: [NSAttributedString.Key: Any] {
19 | self.attributes(at: 0, longestEffectiveRange: nil, in: self.string.fullRange)
20 | }
21 |
22 | /**
23 | Calculates all the various fonts that exist within this NSAttributedString
24 | - returns: All NSFonts that are used in this NSAttributedString
25 | */
26 | var allFonts: [NSFont] {
27 | var fonts = [NSFont]()
28 | self.enumerateAttribute(NSAttributedString.Key.font, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, stop) in
29 | let font = value as! NSFont
30 | fonts.append(font)
31 | })
32 |
33 | return fonts
34 | }
35 |
36 | /**
37 | Calculates all the various text alignments that exist within this NSAttributedString
38 | - returns: All NSTextAlignments that are used in this NSAttributedString
39 | */
40 | var allAlignments: [NSTextAlignment] {
41 | var alignments = [NSTextAlignment]()
42 | self.enumerateAttribute(NSAttributedString.Key.paragraphStyle, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, stop) in
43 | let paragraphStyle = value as! NSParagraphStyle
44 | let alignment = paragraphStyle.alignment
45 | alignments.append(alignment)
46 | })
47 |
48 | return alignments
49 | }
50 |
51 | /**
52 | Calculates all the text colours that are used in this attributed string
53 | - returns: An array of NSColors, representing all the text colours in this attributed string
54 | */
55 | var allTextColours: [NSColor] {
56 | var colours = [NSColor]()
57 | self.enumerateAttribute(.foregroundColor, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, finished) in
58 | if value != nil {
59 | if let colour = value as? NSColor {
60 | colours.append(colour)
61 | }
62 | }
63 |
64 | //If the value is nil, it's the default NSColor value, which is black
65 | else {
66 | colours.append(NSColor.black)
67 | }
68 | })
69 |
70 | return colours
71 | }
72 |
73 | var allHighlightColours: [NSColor] {
74 | var colours = [NSColor]()
75 | self.enumerateAttribute(.backgroundColor, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, finished) in
76 | if value != nil {
77 | if let colour = value as? NSColor {
78 | colours.append(colour)
79 | }
80 | }
81 |
82 | //If the value is nil, it's the default NSColor value, which is white
83 | else {
84 | colours.append(NSColor.white)
85 | }
86 | })
87 |
88 | return colours
89 | }
90 |
91 | /**
92 | Calculates all the NSTextAttachments that are contained in this attributed string
93 | - returns: An array of NSTextAttachments, representing all the attachments in this attributed string
94 | */
95 | var allAttachments: [NSTextAttachment] {
96 | var attachments = [NSTextAttachment]()
97 | self.enumerateAttribute(.attachment, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, finished) in
98 | if value != nil {
99 | if let attachment = value as? NSTextAttachment {
100 | attachments.append(attachment)
101 | }
102 | }
103 | })
104 |
105 | return attachments
106 | }
107 |
108 | // MARK: - Attribute Checking
109 |
110 | /**
111 | Iterates over every font that exists within this NSAttributedString, and checks if any of the fonts contain the desired NSFontTraitMask
112 | - returns: A boolean value, indicative of if this contains our desired trait
113 | */
114 | func contains(trait: NSFontTraitMask) -> Bool {
115 | let allFonts = self.all(of: NSAttributedString.Key.font) as! [NSFont]
116 | for font in allFonts {
117 | if font.contains(trait: trait) {
118 | return true
119 | }
120 | }
121 |
122 | return false
123 | }
124 |
125 | /**
126 | Iterates over every font that exists within this NSAttributedString, and checks if any of the fonts contain the desired NSFontTraitMask
127 | - returns: A boolean value, indicative of if our desired trait could not be found
128 | */
129 | func doesNotContain(trait: NSFontTraitMask) -> Bool {
130 | contains(trait: trait) == false
131 | }
132 |
133 | /**
134 | Determines if this attributed string contains any parts that have the provided NSAttributedString.Key, and any parts that do NOT have the provided NSAttributedString.Key
135 | - parameter key: The NSAttributedString.Key that we're looking for
136 |
137 | - returns: A tuple containing two arguments, atParts & notAtParts. atParts will be true if any part of this
138 | attributed string is present. notAtParts will be true if any part of this attributed string is NOT present.
139 | The two arguments are not mutually exclusive since a string can have an attribute at some parts and
140 | not have the same attributes at other parts.
141 | */
142 | func check(attribute: NSAttributedString.Key) -> (atParts: Bool, notAtParts: Bool) {
143 | var atParts : Bool?
144 | var notAtParts: Bool?
145 |
146 | self.enumerateAttribute(attribute, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(valueOpt, range, stop) in
147 |
148 | // If this can be an NSNumber
149 | if let value = valueOpt as? NSNumber {
150 | // If we have a `none` enum value (by checking with the provided closure)
151 | if value.intValue != 0 {
152 | atParts = true
153 | }
154 |
155 | // If we don't have a `none` enum value
156 | else {
157 | notAtParts = true
158 | }
159 | }
160 |
161 | else if let _ = valueOpt as? NSFont {
162 | atParts = true
163 | }
164 |
165 | else {
166 | notAtParts = true
167 | }
168 | })
169 |
170 | // If noUnderlineAtParts wasn't set and neither was underlineAtParts, then clearly we have no underline
171 | if notAtParts == nil && atParts == nil {
172 | notAtParts = true
173 | }
174 |
175 | // If noUnderlineAtParts wasn't set but underlineAtParts WAS, then clearly we only have underline
176 | if notAtParts == nil && atParts != nil {
177 | notAtParts = false
178 | }
179 |
180 | // If underlineAtParts wasn't set, then clearly we don't have any underline
181 | if atParts == nil {
182 | atParts = false
183 | }
184 |
185 | guard let atParts = atParts, let notAtParts = notAtParts else {
186 | fatalError("`atParts` or `notAtParts` was nil.")
187 | }
188 |
189 | return (atParts, notAtParts)
190 | }
191 |
192 | }
193 |
194 | // MARK: - Basic Attribute Fetching
195 |
196 | private extension NSAttributedString {
197 |
198 | /**
199 | Collects all the types of the attribute that we're after
200 | - parameter attribute: The NSAttributedString.Key values we're searching for
201 | - returns: An array of all the values that correlated with the provided attribute key
202 | */
203 | func all(of attribute: NSAttributedString.Key) -> [Any] {
204 | var allValues = [Any]()
205 | let fullRange = self.string.fullRange
206 | let options = NSAttributedString.EnumerationOptions.longestEffectiveRangeNotRequired
207 |
208 | self.enumerateAttribute(attribute, in: fullRange, options: options, using: {(valueOpt, range, stop) in
209 | if let value = valueOpt {
210 | allValues.append(value)
211 | }
212 | })
213 |
214 | return allValues
215 | }
216 |
217 | }
218 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditor/RichEditor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditor.swift
3 | // RichEditor
4 | //
5 | // Created by Will Lumley on 30/1/20.
6 | //
7 |
8 | import Foundation
9 | import AppKit
10 |
11 | public class RichEditor: NSView {
12 |
13 | // MARK: - Types
14 |
15 | enum CommandShortcut: String {
16 | case b = "b"
17 | case i = "i"
18 | case u = "u"
19 | case plus = "+"
20 | case minus = "-"
21 | }
22 |
23 | // MARK: - Properties
24 |
25 | //The NSTextView stack
26 | /*------------------------------------------------------------*/
27 | public private(set) lazy var textStorage = NSTextStorage()
28 | public private(set) lazy var layoutManager = NSLayoutManager()
29 | public private(set) lazy var textContainer = NSTextContainer()
30 | public private(set) lazy var textView = RichTextView(frame: CGRect(), textContainer: self.textContainer, delegate: self)
31 | public private(set) lazy var scrollview = NSScrollView()
32 | /*------------------------------------------------------------*/
33 |
34 | /// The TextStyling that contains information of the 'relevant' text
35 | internal var selectedTextFontStyling: TextStyling? {
36 | didSet {
37 | self.richEditorDelegate?.fontStylingChanged(self.textStyling)
38 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling)
39 | }
40 | }
41 |
42 | /// The marker that will be used for bullet points
43 | internal static var bulletPointMarker = "•\u{00A0}" //NSTextList.MarkerFormat.circle
44 |
45 | /// Returns the TextStyling object that was derived from the selected text, or the future text if nothing is selected
46 | public var textStyling: TextStyling {
47 | self.selectedTextFontStyling ?? TextStyling(typingAttributes: self.textView.typingAttributes)
48 | }
49 |
50 | /// The delegate which will notify the listener of significant events
51 | public var richEditorDelegate: RichEditorDelegate?
52 |
53 | /// The toolbar object, allowing for users to easily apply styling to their text
54 | internal var toolbar: RichEditorToolbar?
55 |
56 | /// The delegate which will notify the listener of significant events
57 | internal var toolbarRichEditorDelegate: RichEditorDelegate?
58 |
59 | /// Returns the NSFont object that was derived from the selected text, or the future text if nothing is selected
60 | public var currentFont: NSFont {
61 |
62 | // If we have highlighted text, we'll analyse the font of the highlighted text
63 | if self.textView.hasSelectedText {
64 | let range = self.textView.selectedRange()
65 |
66 | // Create an attributed string out of ONLY the highlighted text
67 | guard let attr = self.textView.attributedSubstring(forProposedRange: range, actualRange: nil) else {
68 | fatalError("Failed to create AttributedString.")
69 | }
70 |
71 | let fonts = attr.allFonts
72 | if fonts.count < 1 {
73 | fatalError("AttributedString had no fonts: \(attr)")
74 | }
75 |
76 | return fonts[0]
77 | }
78 |
79 | // We have not highlighted text, so we'll just use the 'future' font
80 | else {
81 | let typingAttributes = self.textView.typingAttributes
82 |
83 | let font = typingAttributes[NSAttributedString.Key.font] as! NSFont
84 | return font
85 | }
86 | }
87 |
88 | // MARK: - NSView
89 |
90 | override init(frame frameRect: NSRect) {
91 | super.init(frame: frameRect)
92 | self.setup()
93 | }
94 |
95 | required init?(coder decoder: NSCoder) {
96 | super.init(coder: decoder)
97 | self.setup()
98 | }
99 |
100 | }
101 |
102 | // MARK: - Private Interface
103 |
104 | private extension RichEditor {
105 |
106 | /**
107 | Perform the initial setup operations to get a functional NSTextView running
108 | */
109 | func setup() {
110 | /// This line of code here is disgusting.
111 | /// The only reason it's here is because of an NSTextView issue that only pops up
112 | /// when launching this library from a iOS -> Catalyst application. A very edge case scenario
113 | /// but one that we should account for.
114 | ///
115 | /// I'll be on the lookout for when Apple fixes this :)
116 | ///
117 | guard self.scrollview.contentSize != .zero else {
118 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
119 | self.setup()
120 | }
121 | return
122 | }
123 | self.textView.delegate = self
124 |
125 | self.configureTextView(isHorizontalScrollingEnabled: false)
126 |
127 | self.configureTextViewLayout()
128 |
129 | self.textView.textStorage?.delegate = self
130 | self.textView.layoutManager?.defaultAttachmentScaling = NSImageScaling.scaleProportionallyDown
131 |
132 | self.selectedTextFontStyling = nil
133 | }
134 |
135 | func configureTextViewLayout() {
136 | self.scrollview.translatesAutoresizingMaskIntoConstraints = false
137 | self.addSubview(self.scrollview)
138 |
139 | // If there is a toolbar, attach our scroll view to the bottom our toolbar
140 | if let toolbar = self.toolbar {
141 | NSLayoutConstraint.activate([
142 | self.scrollview.widthAnchor.constraint(equalTo: self.widthAnchor),
143 | self.scrollview.topAnchor.constraint(equalTo: toolbar.bottomAnchor, constant: 5),
144 | self.scrollview.bottomAnchor.constraint(equalTo: self.bottomAnchor),
145 | ])
146 | }
147 |
148 | // If there is NOT a toolbar, attach our scroll view to the bottom of our view
149 | else {
150 | NSLayoutConstraint.activate([
151 | self.scrollview.widthAnchor.constraint(equalTo: self.widthAnchor),
152 | self.scrollview.topAnchor.constraint(equalTo: self.topAnchor),
153 | self.scrollview.bottomAnchor.constraint(equalTo: self.bottomAnchor),
154 | ])
155 | }
156 | }
157 |
158 | }
159 |
160 | // MARK: - Public Interface
161 |
162 | public extension RichEditor {
163 |
164 | func configureToolbar() {
165 | self.toolbar = RichEditorToolbar(richEditor: self)
166 | self.toolbarRichEditorDelegate = self.toolbar
167 |
168 | guard let toolbar = self.toolbar else {
169 | return
170 | }
171 |
172 | toolbar.translatesAutoresizingMaskIntoConstraints = false
173 | self.addSubview(toolbar)
174 |
175 | toolbar.wantsLayer = true
176 | toolbar.layer?.backgroundColor = NSColor.clear.cgColor
177 |
178 | NSLayoutConstraint.activate([
179 | toolbar.topAnchor.constraint(equalTo: self.topAnchor),
180 | toolbar.widthAnchor.constraint(equalTo: self.widthAnchor),
181 | toolbar.heightAnchor.constraint(equalToConstant: 35)
182 | ])
183 |
184 | self.scrollview.removeFromSuperview()
185 |
186 | self.scrollview.translatesAutoresizingMaskIntoConstraints = false
187 | self.addSubview(self.scrollview)
188 |
189 | NSLayoutConstraint.activate([
190 | self.scrollview.widthAnchor.constraint(equalTo: self.widthAnchor),
191 | self.scrollview.topAnchor.constraint(equalTo: toolbar.bottomAnchor, constant: 5),
192 | self.scrollview.bottomAnchor.constraint(equalTo: self.bottomAnchor),
193 | ])
194 | }
195 |
196 | /**
197 | Uses the NSTextView's attributed string to create a HTML string that
198 | represents the content held within the NSTextView.
199 | The NSAttribtedString -> HTML String conversion uses the cocoa
200 | native data(from...) function.
201 | - throws: An error, if the NSAttribtedString -> HTML String conversion fails
202 | - returns: The string object that is the HTML
203 | */
204 | func html() throws -> String? {
205 | let attrStr = self.textView.attributedString()
206 | let documentAttributes = [
207 | NSAttributedString.DocumentAttributeKey.documentType: NSAttributedString.DocumentType.html,
208 | NSAttributedString.DocumentAttributeKey.characterEncoding: String.Encoding.utf8.rawValue
209 | ] as [NSAttributedString.DocumentAttributeKey: Any]
210 |
211 | let htmlData = try attrStr.data(from: attrStr.string.fullRange, documentAttributes: documentAttributes)
212 | if var htmlString = String(data: htmlData, encoding: .utf8) {
213 |
214 | // Iterate over each attachment, and replace each "file://" component with the image
215 | let allAttachments = self.textView.attributedString().allAttachments
216 | for attachment in allAttachments {
217 | guard let imageID = attachment.fileWrapper?.filename else {
218 | continue
219 | }
220 |
221 | htmlString = htmlString.replacingOccurrences(of: "file:///\(imageID)", with: imageID)
222 | }
223 |
224 | return htmlString
225 | }
226 |
227 | return nil
228 | }
229 |
230 | }
231 |
232 | // MARK: - NSTextStorage Delegate
233 |
234 | extension RichEditor: NSTextStorageDelegate {
235 |
236 | public func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) {
237 | self.richEditorDelegate?.richEditorTextChanged(self)
238 | self.toolbarRichEditorDelegate?.richEditorTextChanged(self)
239 | }
240 |
241 | }
242 |
--------------------------------------------------------------------------------
/Sources/RichEditor/Classes/RichEditorToolbar/RichEditorToolbar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RichEditorToolbar.swift
3 | // RichEditor
4 | //
5 | // Created by William Lumley on 7/12/20.
6 | //
7 |
8 | import AppKit
9 | import macColorPicker
10 |
11 | class RichEditorToolbar: NSView {
12 |
13 | // MARK: - Properties
14 |
15 | private let richEditor: RichEditor
16 |
17 | internal let contentStackView = NSStackView()
18 |
19 | internal let fontFamiliesPopUpButton = NSPopUpButton(frame: .zero)
20 | internal let fontSizePopUpButton = NSPopUpButton(frame: .zero)
21 |
22 | internal let boldButton = RichEditorToolbarButton(imageName: "white-weight-bold")
23 | internal let italicButton = RichEditorToolbarButton(imageName: "white-weight-italic")
24 | internal let underlineButton = RichEditorToolbarButton(imageName: "white-weight-underline")
25 |
26 | internal let alignLeftButton = RichEditorToolbarButton(imageName: "white-align-left")
27 | internal let alignRightButton = RichEditorToolbarButton(imageName: "white-align-right")
28 | internal let alignCentreButton = RichEditorToolbarButton(imageName: "white-align-centre")
29 | internal let alignJustifyButton = RichEditorToolbarButton(imageName: "white-align-justify")
30 |
31 | internal let textColorButton = ColorPicker(frame: .zero)
32 | internal let highlightColorButton = ColorPicker(frame: .zero)
33 |
34 | internal let linkButton = RichEditorToolbarButton(imageName: "white-text-link")
35 | internal let listButton = RichEditorToolbarButton(imageName: "white-text-list")
36 | internal let strikethroughButton = RichEditorToolbarButton(imageName: "white-text-strikethrough")
37 | internal let addImageButton = RichEditorToolbarButton(imageName: "white-text-image")
38 |
39 | // MARK: - NSView
40 |
41 | init(richEditor: RichEditor) {
42 | self.richEditor = richEditor
43 |
44 | super.init(frame: .zero)
45 |
46 | self.setupUI()
47 | }
48 |
49 | override init(frame frameRect: NSRect) {
50 | fatalError("RichEditorToolbar does not support init(frame:)")
51 | }
52 |
53 | required init?(coder decoder: NSCoder) {
54 | fatalError("RichEditorToolbar does not support init(coder:)")
55 | }
56 |
57 | private func setupUI() {
58 | self.contentStackView.alignment = .centerY
59 | self.contentStackView.spacing = 8
60 | self.contentStackView.distribution = .gravityAreas
61 |
62 | // self.contentStackView.wantsLayer = true
63 | // self.contentStackView.layer?.backgroundColor = NSColor.blue.cgColor
64 |
65 | self.contentStackView.translatesAutoresizingMaskIntoConstraints = false
66 | self.addSubview(self.contentStackView)
67 |
68 | NSLayoutConstraint.activate([
69 | self.contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8),
70 | self.contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
71 | self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 6),
72 | self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2),
73 | ])
74 |
75 | self.setupFontUI()
76 | self.contentStackView.addSeperatorView()
77 | self.setupWeightButtons()
78 | self.contentStackView.addSeperatorView()
79 | self.setupAlignButtons()
80 | self.contentStackView.addSeperatorView()
81 | self.setupColorButtons()
82 | self.contentStackView.addSeperatorView()
83 | self.setupCustomTextActionButtons()
84 | self.contentStackView.addSeperatorView()
85 | }
86 |
87 | }
88 |
89 | // MARK: - RichEditorDelegate
90 |
91 | extension RichEditorToolbar: RichEditorDelegate {
92 |
93 | func fontStylingChanged(_ textStyling: TextStyling) {
94 | self.configureUI(with: textStyling)
95 | }
96 |
97 | func richEditorTextChanged(_ richEditor: RichEditor) {
98 |
99 | }
100 |
101 | }
102 |
103 | // MARK: - Actions
104 |
105 | internal extension RichEditorToolbar {
106 |
107 | @objc
108 | func fontFamiliesButtonClicked(_ sender: NSPopUpButton) {
109 | self.applyFont()
110 | }
111 |
112 | @objc
113 | func fontSizeButtonClicked(_ sender: NSPopUpButton) {
114 | self.applyFont()
115 | }
116 |
117 | @objc
118 | func boldButtonClicked(_ sender: RichEditorToolbarButton) {
119 | self.richEditor.toggleBold()
120 | }
121 |
122 | @objc
123 | func italicButtonClicked(_ sender: RichEditorToolbarButton) {
124 | self.richEditor.toggleItalic()
125 | }
126 |
127 | @objc
128 | func underlineButtonClicked(_ sender: RichEditorToolbarButton) {
129 | self.richEditor.toggleUnderline(.single)
130 | }
131 |
132 | @objc
133 | func alignLeftButtonClicked(_ sender: RichEditorToolbarButton) {
134 | self.richEditor.apply(alignment: .left)
135 | }
136 |
137 | @objc
138 | func alignCentreButtonClicked(_ sender: RichEditorToolbarButton) {
139 | self.richEditor.apply(alignment: .center)
140 | }
141 |
142 | @objc
143 | func alignRightButtonClicked(_ sender: RichEditorToolbarButton) {
144 | self.richEditor.apply(alignment: .right)
145 | }
146 |
147 | @objc
148 | func alignJustifyButtonClicked(_ sender: RichEditorToolbarButton) {
149 | self.richEditor.apply(alignment: .justified)
150 | }
151 |
152 | @objc
153 | func linkButtonClicked(_ sender: RichEditorToolbarButton) {
154 | let nameTextField = NSTextField(frame: NSRect(x: 0, y: 28, width: 200, height: 20))
155 | nameTextField.placeholderString = "Link Name"
156 |
157 | let urlTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 20))
158 | urlTextField.placeholderString = "Link URL"
159 |
160 | let textFieldView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 48))
161 | textFieldView.addSubview(nameTextField)
162 | textFieldView.addSubview(urlTextField)
163 |
164 | let alert = NSAlert()
165 | alert.messageText = "Please provide the link name and the link URL"
166 | alert.addButton(withTitle: "Add Link")
167 | alert.addButton(withTitle: "Cancel")
168 | alert.accessoryView = textFieldView
169 |
170 | alert.window.initialFirstResponder = nameTextField
171 | nameTextField.nextKeyView = urlTextField
172 |
173 | let selectedButton = alert.runModal()
174 |
175 | switch selectedButton.rawValue {
176 | case 1000:
177 | let name = nameTextField.stringValue
178 | let url = urlTextField.stringValue
179 |
180 | self.richEditor.insert(link: url, with: name)
181 | default:
182 | print("Unknown raw value selected: \(selectedButton)")
183 | }
184 | }
185 |
186 | @objc
187 | func listButtonClicked(_ sender: RichEditorToolbarButton) {
188 | self.richEditor.startBulletPoints()
189 | }
190 |
191 | @objc
192 | func strikethroughButtonClicked(_ sender: RichEditorToolbarButton) {
193 | self.richEditor.toggleStrikethrough(.single)
194 | }
195 |
196 | @objc
197 | func addImageButtonClicked(_ sender: RichEditorToolbarButton) {
198 | self.richEditor.promptUserForAttachments(windowForModal: self.window)
199 | }
200 |
201 | }
202 |
203 | // MARK: - ColorPickerDelegate
204 |
205 | extension RichEditorToolbar: ColorPickerDelegate {
206 |
207 | func didSelectColor(_ sender: ColorPicker, color: NSColor) {
208 | switch sender {
209 | case self.textColorButton:
210 | self.richEditor.apply(textColour: color)
211 | case self.highlightColorButton:
212 | self.richEditor.apply(highlightColour: color)
213 | default:()
214 | }
215 | }
216 |
217 | }
218 |
219 | // MARK: - Private Extensions
220 |
221 | private extension RichEditorToolbar {
222 |
223 | var toolbarButtons: [RichEditorToolbarButton] {
224 | [
225 | self.boldButton,
226 | self.italicButton,
227 | self.underlineButton,
228 |
229 | self.alignLeftButton,
230 | self.alignCentreButton,
231 | self.alignRightButton,
232 | self.alignJustifyButton,
233 |
234 | self.linkButton,
235 | self.listButton,
236 | self.strikethroughButton,
237 | self.addImageButton,
238 | ]
239 | }
240 |
241 | var alignmentButtons: [RichEditorToolbarButton] {
242 | [
243 | self.alignLeftButton,
244 | self.alignCentreButton,
245 | self.alignRightButton,
246 | self.alignJustifyButton,
247 | ]
248 | }
249 |
250 | }
251 |
252 | private extension RichEditorToolbar {
253 |
254 | /**
255 | Grabs the selected font title and the selected font size, creates an instance of NSFont from them
256 | and applies it to the RichEditor
257 | */
258 | func applyFont() {
259 | guard let selectedFontNameMenuItem = self.fontFamiliesPopUpButton.selectedItem else {
260 | return
261 | }
262 |
263 | guard let selectedFontSizeMenuItem = self.fontSizePopUpButton.selectedItem else {
264 | return
265 | }
266 |
267 | let selectedFontTitle = selectedFontNameMenuItem.title
268 | let selectedFontSize = CGFloat((selectedFontSizeMenuItem.title as NSString).doubleValue)
269 |
270 | guard let font = NSFont(name: selectedFontTitle, size: selectedFontSize) else {
271 | return
272 | }
273 |
274 | self.richEditor.apply(font: font)
275 | }
276 |
277 | func configureUI(with textStyling: TextStyling) {
278 | self.boldButton.selected = textStyling.boldTrait != .isNotTrait
279 | self.italicButton.selected = textStyling.italicsTrait != .isNotTrait
280 | self.underlineButton.selected = textStyling.underlineTrait != .isNotTrait
281 | self.strikethroughButton.selected = textStyling.strikethroughTrait != .isNotTrait
282 |
283 | // Configure the TextColour UI
284 | let textColours = textStyling.textColours
285 | switch (textColours.count) {
286 | case 0:
287 | self.textColorButton.selectedColor = NSColor.white
288 | case 1:
289 | self.textColorButton.selectedColor = textColours[0]
290 | case 2:
291 | self.textColorButton.selectedColor = NSColor.gray
292 | default:()
293 | }
294 |
295 | // Configure the HighlightColour UI
296 | let highlightColours = textStyling.highlightColours
297 | switch (highlightColours.count) {
298 | case 0:
299 | self.highlightColorButton.selectedColor = NSColor.white
300 | case 1:
301 | self.highlightColorButton.selectedColor = highlightColours[0]
302 | case 2:
303 | self.highlightColorButton.selectedColor = NSColor.gray
304 | default:()
305 | }
306 |
307 | // Configure the Fonts UI
308 | let fonts = textStyling.fonts
309 | switch (fonts.count) {
310 | case 0:
311 | fatalError("Fonts count is somehow 0: \(fonts)")
312 |
313 | case 1:
314 | self.fontFamiliesPopUpButton.title = fonts[0].displayName ?? fonts[0].fontName
315 | self.fontSizePopUpButton.title = "\(fonts[0].pointSize.cleanValue)"
316 |
317 | case 2:
318 | self.fontFamiliesPopUpButton.title = fonts[0].displayName ?? fonts[0].fontName
319 | self.fontSizePopUpButton.title = "\(fonts[0].pointSize.cleanValue)"
320 |
321 | default:()
322 | }
323 |
324 | // Configure the Alignments UI
325 | let alignments = textStyling.alignments
326 |
327 | self.alignmentButtons.forEach { $0.selected = false }
328 |
329 | for alignment in alignments {
330 | switch alignment {
331 | case .left:
332 | self.alignLeftButton.selected = true
333 | case .center:
334 | self.alignCentreButton.selected = true
335 | case .right:
336 | self.alignRightButton.selected = true
337 | case .justified:
338 | self.alignJustifyButton.selected = true
339 | default:()
340 | }
341 | }
342 | }
343 |
344 | }
345 |
346 | // MARK: - NSStackView
347 |
348 | private extension NSStackView {
349 |
350 | func addSeperatorView() {
351 | let seperator = NSView()
352 |
353 | seperator.wantsLayer = true
354 | seperator.layer?.backgroundColor = NSColor.lightGray.cgColor
355 |
356 | self.addArrangedSubview(seperator)
357 |
358 | seperator.translatesAutoresizingMaskIntoConstraints = false
359 | NSLayoutConstraint.activate([
360 | seperator.widthAnchor.constraint(equalToConstant: 1)
361 | ])
362 | }
363 |
364 | }
365 |
--------------------------------------------------------------------------------
/RichEditorExample/RichEditorExample.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 60;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | BE96A1F42B5A216500917EE3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A1F32B5A216500917EE3 /* AppDelegate.swift */; };
11 | BE96A1F62B5A216500917EE3 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A1F52B5A216500917EE3 /* ViewController.swift */; };
12 | BE96A1F82B5A216600917EE3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BE96A1F72B5A216600917EE3 /* Assets.xcassets */; };
13 | BE96A1FB2B5A216700917EE3 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BE96A1F92B5A216700917EE3 /* Main.storyboard */; };
14 | BE96A2042B5A21F200917EE3 /* RichEditor in Frameworks */ = {isa = PBXBuildFile; productRef = BE96A2032B5A21F200917EE3 /* RichEditor */; };
15 | BE96A2092B5A221700917EE3 /* PreviewWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2052B5A221700917EE3 /* PreviewWebViewController.swift */; };
16 | BE96A20A2B5A221700917EE3 /* PreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2062B5A221700917EE3 /* PreviewViewController.swift */; };
17 | BE96A20B2B5A221700917EE3 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2072B5A221700917EE3 /* NSColor+Hex.swift */; };
18 | BE96A20C2B5A221700917EE3 /* PreviewTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2082B5A221700917EE3 /* PreviewTextViewController.swift */; };
19 | /* End PBXBuildFile section */
20 |
21 | /* Begin PBXFileReference section */
22 | BE96A1F02B5A216500917EE3 /* RichEditorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RichEditorExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
23 | BE96A1F32B5A216500917EE3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
24 | BE96A1F52B5A216500917EE3 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
25 | BE96A1F72B5A216600917EE3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
26 | BE96A1FA2B5A216700917EE3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
27 | BE96A1FC2B5A216700917EE3 /* RichEditorExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RichEditorExample.entitlements; sourceTree = ""; };
28 | BE96A2052B5A221700917EE3 /* PreviewWebViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewWebViewController.swift; sourceTree = ""; };
29 | BE96A2062B5A221700917EE3 /* PreviewViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewViewController.swift; sourceTree = ""; };
30 | BE96A2072B5A221700917EE3 /* NSColor+Hex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSColor+Hex.swift"; sourceTree = ""; };
31 | BE96A2082B5A221700917EE3 /* PreviewTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewTextViewController.swift; sourceTree = ""; };
32 | /* End PBXFileReference section */
33 |
34 | /* Begin PBXFrameworksBuildPhase section */
35 | BE96A1ED2B5A216500917EE3 /* Frameworks */ = {
36 | isa = PBXFrameworksBuildPhase;
37 | buildActionMask = 2147483647;
38 | files = (
39 | BE96A2042B5A21F200917EE3 /* RichEditor in Frameworks */,
40 | );
41 | runOnlyForDeploymentPostprocessing = 0;
42 | };
43 | /* End PBXFrameworksBuildPhase section */
44 |
45 | /* Begin PBXGroup section */
46 | BE96A1E72B5A216500917EE3 = {
47 | isa = PBXGroup;
48 | children = (
49 | BE96A1F22B5A216500917EE3 /* RichEditorExample */,
50 | BE96A1F12B5A216500917EE3 /* Products */,
51 | );
52 | sourceTree = "";
53 | };
54 | BE96A1F12B5A216500917EE3 /* Products */ = {
55 | isa = PBXGroup;
56 | children = (
57 | BE96A1F02B5A216500917EE3 /* RichEditorExample.app */,
58 | );
59 | name = Products;
60 | sourceTree = "";
61 | };
62 | BE96A1F22B5A216500917EE3 /* RichEditorExample */ = {
63 | isa = PBXGroup;
64 | children = (
65 | BE96A1F32B5A216500917EE3 /* AppDelegate.swift */,
66 | BE96A2072B5A221700917EE3 /* NSColor+Hex.swift */,
67 | BE96A1F72B5A216600917EE3 /* Assets.xcassets */,
68 | BE96A20D2B5A222500917EE3 /* ViewControllers */,
69 | BE96A1F92B5A216700917EE3 /* Main.storyboard */,
70 | BE96A1FC2B5A216700917EE3 /* RichEditorExample.entitlements */,
71 | );
72 | path = RichEditorExample;
73 | sourceTree = "";
74 | };
75 | BE96A20D2B5A222500917EE3 /* ViewControllers */ = {
76 | isa = PBXGroup;
77 | children = (
78 | BE96A1F52B5A216500917EE3 /* ViewController.swift */,
79 | BE96A2082B5A221700917EE3 /* PreviewTextViewController.swift */,
80 | BE96A2062B5A221700917EE3 /* PreviewViewController.swift */,
81 | BE96A2052B5A221700917EE3 /* PreviewWebViewController.swift */,
82 | );
83 | path = ViewControllers;
84 | sourceTree = "";
85 | };
86 | /* End PBXGroup section */
87 |
88 | /* Begin PBXNativeTarget section */
89 | BE96A1EF2B5A216500917EE3 /* RichEditorExample */ = {
90 | isa = PBXNativeTarget;
91 | buildConfigurationList = BE96A1FF2B5A216700917EE3 /* Build configuration list for PBXNativeTarget "RichEditorExample" */;
92 | buildPhases = (
93 | BE96A1EC2B5A216500917EE3 /* Sources */,
94 | BE96A1ED2B5A216500917EE3 /* Frameworks */,
95 | BE96A1EE2B5A216500917EE3 /* Resources */,
96 | );
97 | buildRules = (
98 | );
99 | dependencies = (
100 | );
101 | name = RichEditorExample;
102 | packageProductDependencies = (
103 | BE96A2032B5A21F200917EE3 /* RichEditor */,
104 | );
105 | productName = RichEditorExample;
106 | productReference = BE96A1F02B5A216500917EE3 /* RichEditorExample.app */;
107 | productType = "com.apple.product-type.application";
108 | };
109 | /* End PBXNativeTarget section */
110 |
111 | /* Begin PBXProject section */
112 | BE96A1E82B5A216500917EE3 /* Project object */ = {
113 | isa = PBXProject;
114 | attributes = {
115 | BuildIndependentTargetsInParallel = 1;
116 | LastSwiftUpdateCheck = 1520;
117 | LastUpgradeCheck = 1520;
118 | TargetAttributes = {
119 | BE96A1EF2B5A216500917EE3 = {
120 | CreatedOnToolsVersion = 15.2;
121 | };
122 | };
123 | };
124 | buildConfigurationList = BE96A1EB2B5A216500917EE3 /* Build configuration list for PBXProject "RichEditorExample" */;
125 | compatibilityVersion = "Xcode 14.0";
126 | developmentRegion = en;
127 | hasScannedForEncodings = 0;
128 | knownRegions = (
129 | en,
130 | Base,
131 | );
132 | mainGroup = BE96A1E72B5A216500917EE3;
133 | packageReferences = (
134 | BE96A2022B5A21F200917EE3 /* XCLocalSwiftPackageReference ".." */,
135 | );
136 | productRefGroup = BE96A1F12B5A216500917EE3 /* Products */;
137 | projectDirPath = "";
138 | projectRoot = "";
139 | targets = (
140 | BE96A1EF2B5A216500917EE3 /* RichEditorExample */,
141 | );
142 | };
143 | /* End PBXProject section */
144 |
145 | /* Begin PBXResourcesBuildPhase section */
146 | BE96A1EE2B5A216500917EE3 /* Resources */ = {
147 | isa = PBXResourcesBuildPhase;
148 | buildActionMask = 2147483647;
149 | files = (
150 | BE96A1F82B5A216600917EE3 /* Assets.xcassets in Resources */,
151 | BE96A1FB2B5A216700917EE3 /* Main.storyboard in Resources */,
152 | );
153 | runOnlyForDeploymentPostprocessing = 0;
154 | };
155 | /* End PBXResourcesBuildPhase section */
156 |
157 | /* Begin PBXSourcesBuildPhase section */
158 | BE96A1EC2B5A216500917EE3 /* Sources */ = {
159 | isa = PBXSourcesBuildPhase;
160 | buildActionMask = 2147483647;
161 | files = (
162 | BE96A20C2B5A221700917EE3 /* PreviewTextViewController.swift in Sources */,
163 | BE96A1F62B5A216500917EE3 /* ViewController.swift in Sources */,
164 | BE96A20B2B5A221700917EE3 /* NSColor+Hex.swift in Sources */,
165 | BE96A1F42B5A216500917EE3 /* AppDelegate.swift in Sources */,
166 | BE96A2092B5A221700917EE3 /* PreviewWebViewController.swift in Sources */,
167 | BE96A20A2B5A221700917EE3 /* PreviewViewController.swift in Sources */,
168 | );
169 | runOnlyForDeploymentPostprocessing = 0;
170 | };
171 | /* End PBXSourcesBuildPhase section */
172 |
173 | /* Begin PBXVariantGroup section */
174 | BE96A1F92B5A216700917EE3 /* Main.storyboard */ = {
175 | isa = PBXVariantGroup;
176 | children = (
177 | BE96A1FA2B5A216700917EE3 /* Base */,
178 | );
179 | name = Main.storyboard;
180 | sourceTree = "";
181 | };
182 | /* End PBXVariantGroup section */
183 |
184 | /* Begin XCBuildConfiguration section */
185 | BE96A1FD2B5A216700917EE3 /* Debug */ = {
186 | isa = XCBuildConfiguration;
187 | buildSettings = {
188 | ALWAYS_SEARCH_USER_PATHS = NO;
189 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
190 | CLANG_ANALYZER_NONNULL = YES;
191 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
192 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
193 | CLANG_ENABLE_MODULES = YES;
194 | CLANG_ENABLE_OBJC_ARC = YES;
195 | CLANG_ENABLE_OBJC_WEAK = YES;
196 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
197 | CLANG_WARN_BOOL_CONVERSION = YES;
198 | CLANG_WARN_COMMA = YES;
199 | CLANG_WARN_CONSTANT_CONVERSION = YES;
200 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
201 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
202 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
203 | CLANG_WARN_EMPTY_BODY = YES;
204 | CLANG_WARN_ENUM_CONVERSION = YES;
205 | CLANG_WARN_INFINITE_RECURSION = YES;
206 | CLANG_WARN_INT_CONVERSION = YES;
207 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
208 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
209 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
210 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
211 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
212 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
213 | CLANG_WARN_STRICT_PROTOTYPES = YES;
214 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
215 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
216 | CLANG_WARN_UNREACHABLE_CODE = YES;
217 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
218 | COPY_PHASE_STRIP = NO;
219 | DEBUG_INFORMATION_FORMAT = dwarf;
220 | ENABLE_STRICT_OBJC_MSGSEND = YES;
221 | ENABLE_TESTABILITY = YES;
222 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
223 | GCC_C_LANGUAGE_STANDARD = gnu17;
224 | GCC_DYNAMIC_NO_PIC = NO;
225 | GCC_NO_COMMON_BLOCKS = YES;
226 | GCC_OPTIMIZATION_LEVEL = 0;
227 | GCC_PREPROCESSOR_DEFINITIONS = (
228 | "DEBUG=1",
229 | "$(inherited)",
230 | );
231 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
232 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
233 | GCC_WARN_UNDECLARED_SELECTOR = YES;
234 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
235 | GCC_WARN_UNUSED_FUNCTION = YES;
236 | GCC_WARN_UNUSED_VARIABLE = YES;
237 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
238 | MACOSX_DEPLOYMENT_TARGET = 14.2;
239 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
240 | MTL_FAST_MATH = YES;
241 | ONLY_ACTIVE_ARCH = YES;
242 | SDKROOT = macosx;
243 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
244 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
245 | };
246 | name = Debug;
247 | };
248 | BE96A1FE2B5A216700917EE3 /* Release */ = {
249 | isa = XCBuildConfiguration;
250 | buildSettings = {
251 | ALWAYS_SEARCH_USER_PATHS = NO;
252 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
253 | CLANG_ANALYZER_NONNULL = YES;
254 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
255 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
256 | CLANG_ENABLE_MODULES = YES;
257 | CLANG_ENABLE_OBJC_ARC = YES;
258 | CLANG_ENABLE_OBJC_WEAK = YES;
259 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
260 | CLANG_WARN_BOOL_CONVERSION = YES;
261 | CLANG_WARN_COMMA = YES;
262 | CLANG_WARN_CONSTANT_CONVERSION = YES;
263 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
264 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
265 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
266 | CLANG_WARN_EMPTY_BODY = YES;
267 | CLANG_WARN_ENUM_CONVERSION = YES;
268 | CLANG_WARN_INFINITE_RECURSION = YES;
269 | CLANG_WARN_INT_CONVERSION = YES;
270 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
271 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
272 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
273 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
274 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
275 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
276 | CLANG_WARN_STRICT_PROTOTYPES = YES;
277 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
278 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
279 | CLANG_WARN_UNREACHABLE_CODE = YES;
280 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
281 | COPY_PHASE_STRIP = NO;
282 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
283 | ENABLE_NS_ASSERTIONS = NO;
284 | ENABLE_STRICT_OBJC_MSGSEND = YES;
285 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
286 | GCC_C_LANGUAGE_STANDARD = gnu17;
287 | GCC_NO_COMMON_BLOCKS = YES;
288 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
289 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
290 | GCC_WARN_UNDECLARED_SELECTOR = YES;
291 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
292 | GCC_WARN_UNUSED_FUNCTION = YES;
293 | GCC_WARN_UNUSED_VARIABLE = YES;
294 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
295 | MACOSX_DEPLOYMENT_TARGET = 14.2;
296 | MTL_ENABLE_DEBUG_INFO = NO;
297 | MTL_FAST_MATH = YES;
298 | SDKROOT = macosx;
299 | SWIFT_COMPILATION_MODE = wholemodule;
300 | };
301 | name = Release;
302 | };
303 | BE96A2002B5A216700917EE3 /* Debug */ = {
304 | isa = XCBuildConfiguration;
305 | buildSettings = {
306 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
307 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
308 | CODE_SIGN_ENTITLEMENTS = RichEditorExample/RichEditorExample.entitlements;
309 | CODE_SIGN_STYLE = Automatic;
310 | COMBINE_HIDPI_IMAGES = YES;
311 | CURRENT_PROJECT_VERSION = 1;
312 | DEVELOPMENT_TEAM = 4ELTP9RFTJ;
313 | ENABLE_HARDENED_RUNTIME = YES;
314 | GENERATE_INFOPLIST_FILE = YES;
315 | INFOPLIST_KEY_NSHumanReadableCopyright = "";
316 | INFOPLIST_KEY_NSMainStoryboardFile = Main;
317 | INFOPLIST_KEY_NSPrincipalClass = NSApplication;
318 | LD_RUNPATH_SEARCH_PATHS = (
319 | "$(inherited)",
320 | "@executable_path/../Frameworks",
321 | );
322 | MARKETING_VERSION = 1.0;
323 | PRODUCT_BUNDLE_IDENTIFIER = com.williamlumley.RichEditorExample;
324 | PRODUCT_NAME = "$(TARGET_NAME)";
325 | SWIFT_EMIT_LOC_STRINGS = YES;
326 | SWIFT_VERSION = 5.0;
327 | };
328 | name = Debug;
329 | };
330 | BE96A2012B5A216700917EE3 /* Release */ = {
331 | isa = XCBuildConfiguration;
332 | buildSettings = {
333 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
334 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
335 | CODE_SIGN_ENTITLEMENTS = RichEditorExample/RichEditorExample.entitlements;
336 | CODE_SIGN_STYLE = Automatic;
337 | COMBINE_HIDPI_IMAGES = YES;
338 | CURRENT_PROJECT_VERSION = 1;
339 | DEVELOPMENT_TEAM = 4ELTP9RFTJ;
340 | ENABLE_HARDENED_RUNTIME = YES;
341 | GENERATE_INFOPLIST_FILE = YES;
342 | INFOPLIST_KEY_NSHumanReadableCopyright = "";
343 | INFOPLIST_KEY_NSMainStoryboardFile = Main;
344 | INFOPLIST_KEY_NSPrincipalClass = NSApplication;
345 | LD_RUNPATH_SEARCH_PATHS = (
346 | "$(inherited)",
347 | "@executable_path/../Frameworks",
348 | );
349 | MARKETING_VERSION = 1.0;
350 | PRODUCT_BUNDLE_IDENTIFIER = com.williamlumley.RichEditorExample;
351 | PRODUCT_NAME = "$(TARGET_NAME)";
352 | SWIFT_EMIT_LOC_STRINGS = YES;
353 | SWIFT_VERSION = 5.0;
354 | };
355 | name = Release;
356 | };
357 | /* End XCBuildConfiguration section */
358 |
359 | /* Begin XCConfigurationList section */
360 | BE96A1EB2B5A216500917EE3 /* Build configuration list for PBXProject "RichEditorExample" */ = {
361 | isa = XCConfigurationList;
362 | buildConfigurations = (
363 | BE96A1FD2B5A216700917EE3 /* Debug */,
364 | BE96A1FE2B5A216700917EE3 /* Release */,
365 | );
366 | defaultConfigurationIsVisible = 0;
367 | defaultConfigurationName = Release;
368 | };
369 | BE96A1FF2B5A216700917EE3 /* Build configuration list for PBXNativeTarget "RichEditorExample" */ = {
370 | isa = XCConfigurationList;
371 | buildConfigurations = (
372 | BE96A2002B5A216700917EE3 /* Debug */,
373 | BE96A2012B5A216700917EE3 /* Release */,
374 | );
375 | defaultConfigurationIsVisible = 0;
376 | defaultConfigurationName = Release;
377 | };
378 | /* End XCConfigurationList section */
379 |
380 | /* Begin XCLocalSwiftPackageReference section */
381 | BE96A2022B5A21F200917EE3 /* XCLocalSwiftPackageReference ".." */ = {
382 | isa = XCLocalSwiftPackageReference;
383 | relativePath = ..;
384 | };
385 | /* End XCLocalSwiftPackageReference section */
386 |
387 | /* Begin XCSwiftPackageProductDependency section */
388 | BE96A2032B5A21F200917EE3 /* RichEditor */ = {
389 | isa = XCSwiftPackageProductDependency;
390 | productName = RichEditor;
391 | };
392 | /* End XCSwiftPackageProductDependency section */
393 | };
394 | rootObject = BE96A1E82B5A216500917EE3 /* Project object */;
395 | }
396 |
--------------------------------------------------------------------------------
/RichEditorExample/RichEditorExample/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
451 |
452 |
453 |
454 |
455 |
456 |
457 |
458 |
459 |
460 |
461 |
462 |
463 |
464 |
465 |
466 |
467 |
468 |
469 |
470 |
471 |
472 |
473 |
474 |
475 |
476 |
477 |
478 |
479 |
480 |
481 |
482 |
483 |
484 |
485 |
486 |
487 |
488 |
489 |
490 |
491 |
492 |
493 |
494 |
495 |
496 |
497 |
498 |
499 |
500 |
501 |
502 |
503 |
513 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
530 |
531 |
532 |
533 |
534 |
535 |
536 |
537 |
538 |
539 |
540 |
541 |
542 |
543 |
544 |
545 |
546 |
547 |
548 |
549 |
550 |
551 |
552 |
553 |
554 |
555 |
556 |
557 |
558 |
559 |
560 |
561 |
562 |
563 |
564 |
565 |
566 |
567 |
568 |
569 |
570 |
571 |
572 |
573 |
574 |
575 |
576 |
577 |
578 |
579 |
580 |
581 |
582 |
583 |
584 |
585 |
586 |
587 |
--------------------------------------------------------------------------------