├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .swift-version ├── .travis.yml ├── Example ├── .gitignore ├── Podfile ├── Tests │ ├── Info.plist │ ├── VEditorImageNodeSpec.swift │ ├── VEditorMediaNodeSpec.swift │ ├── VEditorMediaPlaceholderNodeSpec.swift │ ├── VEditorNodeSpec.swift │ ├── VEditorParserSpec.swift │ ├── VEditorTextCellNodeSpec.swift │ ├── VEditorTextNodeSpec.swift │ ├── VEditorTextStorageSpec.swift │ ├── VEditorTypingControlNodeSpec.swift │ ├── VEditorVideoNodeSpec.swift │ └── VEditorXMLBuilderSpec.swift ├── VEditorKit.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── VEditorKit-Example.xcscheme ├── VEditorKit.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── VEditorKit │ ├── AppDelegate.swift │ ├── Base.lproj │ └── LaunchScreen.xib │ ├── Controllers │ ├── EditorNodeController.swift │ └── XMLViewController.swift │ ├── EditorServices │ ├── EditorRule.swift │ ├── VImageContent.swift │ ├── VOpenGraphContent.swift │ └── VVideoContent.swift │ ├── Extensions │ └── UIImage+Extension.swift │ ├── Images.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── cancel.imageset │ │ ├── Contents.json │ │ └── cancel.png │ ├── cancel.png │ ├── image.imageset │ │ ├── Contents.json │ │ └── image.png │ ├── image.png │ ├── keyboard.imageset │ │ ├── Contents.json │ │ └── keyboard.png │ ├── keyboard.png │ ├── license.pdf │ ├── video.imageset │ │ ├── Contents.json │ │ └── video.png │ └── video.png │ ├── Info.plist │ ├── Services │ └── MockService.swift │ ├── Views │ ├── EditorControlAreaNode.swift │ └── EditorOpenGraphPlaceholder.swift │ └── content.xml ├── LICENSE ├── README.md ├── VEditorKit.podspec ├── VEditorKit ├── Assets │ └── .gitkeep └── Classes │ ├── .gitkeep │ ├── Platform.swift │ ├── VEditorDeleteMediaNode.swift │ ├── VEditorImageNode.swift │ ├── VEditorMediaNode.swift │ ├── VEditorMediaPlaceholderNode.swift │ ├── VEditorNode.swift │ ├── VEditorOpenGraphNode.swift │ ├── VEditorParser.swift │ ├── VEditorTextCellNode.swift │ ├── VEditorTextNode.swift │ ├── VEditorTextStorage.swift │ ├── VEditorTypingControlNode.swift │ ├── VEditorVideoNode.swift │ └── VEditorXMLBuilder.swift ├── _Pods.xcodeproj ├── codecov.yml └── screenshots ├── english.gif ├── intro.png ├── korean.gif ├── logo.png ├── memory_usage.png ├── placeholder.gif ├── quick_start.png ├── regexAttributeTyping.gif ├── resource1.jpg ├── resource2.jpg ├── test.mp4 ├── test.png ├── test2.mp4 ├── test2.png ├── test3.gif ├── test4.gif └── typingControl.gif /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Why need this change?: 2 | - TODO: Add a comment 3 | 4 | 5 | ## Change made & impact: 6 | - TODO: Add a comment 7 | 8 | 9 | ## Test Scope: 10 | - TODO: Add a comment 11 | 12 | 13 | ## Vertified snapshots (optional) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | *.xccheckout 16 | profile 17 | *.moved-aside 18 | DerivedData 19 | *.hmap 20 | *.ipa 21 | 22 | # Bundler 23 | .bundle 24 | 25 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 26 | # Carthage/Checkouts 27 | 28 | Carthage/Build 29 | 30 | # We recommend against adding the Pods directory to your .gitignore. However 31 | # you should judge for yourself, the pros and cons are mentioned at: 32 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 33 | # 34 | # Note: if you ignore the Pods directory, make sure to uncomment 35 | # `pod install` in .travis.yml 36 | # 37 | # Pods/ 38 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 4.2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: objective-c 2 | osx_image: xcode10 3 | 4 | cache: cocoapods 5 | podfile: Example/Podfile 6 | 7 | before_install: 8 | - pod install --repo-update --project-directory=Example 9 | 10 | branches: 11 | only: 12 | - master 13 | 14 | script: 15 | - xcodebuild clean -workspace Example/VEditorKit.xcworkspace -scheme VEditorKit 16 | - xcodebuild -list -sdk iphonesimulator -workspace Example/VEditorKit.xcworkspace -scheme VEditorKit 17 | - xcodebuild build test -sdk iphonesimulator -workspace Example/VEditorKit.xcworkspace -destination 'platform=iOS Simulator,name=iPhone 6,OS=12.0' -scheme VEditorKit-Example CODE_SIGNING_REQUIRED=NO | xcpretty 18 | 19 | after_success: 20 | - bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /Example/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .Trashes 3 | *.lock 4 | Pods/ 5 | build/**/* 6 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | inhibit_all_warnings! 3 | 4 | target 'VEditorKit_Example' do 5 | pod 'VEditorKit', :path => '../' 6 | pod 'Texture', '2.8' 7 | pod 'RxSwift' 8 | pod 'RxCocoa' 9 | 10 | target 'VEditorKit_Tests' do 11 | inherit! :search_paths 12 | 13 | pod 'Quick' 14 | pod 'Nimble' 15 | pod 'RxTest' 16 | pod 'RxBlocking' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Example/Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/Tests/VEditorImageNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorImageNodeSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import AsyncDisplayKit 13 | import RxBlocking 14 | import VEditorKit 15 | 16 | class VEditorImageNodeSpec: QuickSpec { 17 | 18 | override func spec() { 19 | 20 | describe("VEditorImageNode Unit Test") { 21 | 22 | var node: VEditorImageNode! 23 | 24 | context("intialization test") { 25 | 26 | beforeEach { 27 | node = VEditorImageNode.init(isEdit: true) 28 | } 29 | 30 | it("should be success") { 31 | expect(node.node).to(beAKindOf(ASNetworkImageNode.self)) 32 | expect(node.automaticallyManagesSubnodes).to(beTrue()) 33 | expect(node.isEdit).to(beTrue()) 34 | } 35 | } 36 | 37 | context("update property test") { 38 | 39 | beforeEach { 40 | node = VEditorImageNode.init(isEdit: true) 41 | } 42 | 43 | it("should be update previewImageURL") { 44 | expect(node.node.url).to(beNil()) 45 | node.setURL(URL(string: "https://raw.githubusercontent.com/GeekTree0101/VEditorKit/master/screenshots/intro.png")) 46 | expect(node.node.url).toNot(beNil()) 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Example/Tests/VEditorMediaNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorMeidaNodeSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import AsyncDisplayKit 13 | import RxBlocking 14 | import VEditorKit 15 | 16 | class VEditorMediaNodeSpec: QuickSpec { 17 | 18 | override func spec() { 19 | 20 | describe("VEditorImageNode Unit Test") { 21 | 22 | 23 | context("intialization test") { 24 | 25 | var node: VEditorMediaNode! 26 | var node2: VEditorMediaNode! 27 | 28 | beforeEach { 29 | node = VEditorMediaNode.init(node: ASVideoNode(), 30 | deleteNode: .init(), 31 | isEdit: true) 32 | node2 = VEditorMediaNode.init(node: ASNetworkImageNode(), 33 | deleteNode: .init(), 34 | isEdit: true) 35 | } 36 | 37 | it("should be success") { 38 | expect(node.node).to(beAKindOf(ASVideoNode.self)) 39 | expect(node2.node).to(beAKindOf(ASNetworkImageNode.self)) 40 | 41 | expect(node.isEdit).to(beTrue()) 42 | expect(node.insets).to(equal(.zero)) 43 | expect(node.ratio).to(equal(1.0)) 44 | expect(node.automaticallyManagesSubnodes).to(beTrue()) 45 | expect(node.selectionStyle == .none).to(beTrue()) 46 | } 47 | } 48 | 49 | context("update property test") { 50 | 51 | var node: VEditorMediaNode! 52 | 53 | beforeEach { 54 | node = VEditorMediaNode.init(node: ASVideoNode(), 55 | deleteNode: .init(), 56 | isEdit: true) 57 | } 58 | 59 | it("should be update ratio") { 60 | expect(node.ratio).to(equal(1.0)) 61 | node.setMediaRatio(0.5) 62 | expect(node.ratio).to(equal(0.5)) 63 | } 64 | 65 | it("should be update insets") { 66 | expect(node.insets).to(equal(.zero)) 67 | node.setContentInsets(.init(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0)) 68 | expect(node.insets) 69 | .to(equal(.init(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0))) 70 | } 71 | 72 | it("should be update text insertion touch area height") { 73 | expect(node.textInsertionNode.style.height.value).to(equal(5.0)) 74 | node.setTextInsertionHeight(50.0) 75 | expect(node.textInsertionNode.style.height.value).to(equal(50.0)) 76 | } 77 | } 78 | 79 | context("media tap event test") { 80 | 81 | var node: VEditorMediaNode! 82 | var viewOnlyNode: VEditorMediaNode! 83 | 84 | beforeEach { 85 | node = VEditorMediaNode.init(node: ASVideoNode(), 86 | deleteNode: .init(), 87 | isEdit: true) 88 | viewOnlyNode = VEditorMediaNode.init(node: ASVideoNode(), 89 | deleteNode: .init(), 90 | isEdit: false) 91 | node.didLoad() 92 | viewOnlyNode.didLoad() 93 | } 94 | 95 | it("should be control isHidden status about delete frame") { 96 | expect(node.deleteControlNode.isHidden).to(beTrue()) 97 | node.deleteControlNode.sendActions(forControlEvents: .touchUpInside, with: nil) 98 | expect(node.deleteControlNode.isHidden).to(beFalse()) 99 | node.node.sendActions(forControlEvents: .touchUpInside, with: nil) 100 | expect(node.deleteControlNode.isHidden).to(beTrue()) 101 | } 102 | 103 | it("shouldn't be control isHidden status about delete frame") { 104 | expect(viewOnlyNode.deleteControlNode.isHidden).to(beTrue()) 105 | viewOnlyNode.deleteControlNode.sendActions(forControlEvents: .touchUpInside, with: nil) 106 | expect(viewOnlyNode.deleteControlNode.isHidden).to(beTrue()) 107 | viewOnlyNode.node.sendActions(forControlEvents: .touchUpInside, with: nil) 108 | expect(viewOnlyNode.deleteControlNode.isHidden).to(beTrue()) 109 | } 110 | } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Example/Tests/VEditorMediaPlaceholderNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorMediaPlaceholderNodeSpce.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import RxBlocking 13 | import VEditorKit 14 | 15 | class VEditorMediaPlaceholderNodeSpec: QuickSpec { 16 | 17 | override func spec() { 18 | 19 | describe("VEditorPlaceholderNode Unit Test") { 20 | 21 | var node: VEditorMediaPlaceholderNode! 22 | 23 | context("Intialization test") { 24 | 25 | beforeEach { 26 | node = VEditorMediaPlaceholderNode.init(xmlTag: "a") 27 | } 28 | 29 | it("should be success") { 30 | expect(node.xmlTag).to(equal("a")) 31 | expect(node.automaticallyManagesSubnodes).to(beTrue()) 32 | expect(node.selectionStyle == .none).to(beTrue()) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/Tests/VEditorNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorNodeSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import AsyncDisplayKit 13 | import RxBlocking 14 | import VEditorKit 15 | 16 | class VEditorNodeSpec: QuickSpec { 17 | 18 | let controlAreaNode = EditorControlAreaNode(rule: EditorRule()) 19 | let rule = EditorRule() 20 | 21 | override func spec() { 22 | 23 | describe("VEditorNode Unit Test") { 24 | 25 | var node: VEditorNode! 26 | 27 | context("initialization test") { 28 | 29 | var nonControlAreaEditor: VEditorNode! 30 | 31 | beforeEach { 32 | node = VEditorNode 33 | .init(editorRule: self.rule, 34 | controlAreaNode: self.controlAreaNode) 35 | node.delegate = self 36 | 37 | nonControlAreaEditor = VEditorNode 38 | .init(editorRule: self.rule, 39 | controlAreaNode: nil) 40 | } 41 | 42 | it("should be success") { 43 | expect(node).to(beAKindOf(VEditorNode.self)) 44 | expect(node.automaticallyManagesSubnodes).to(beTrue()) 45 | expect(node.automaticallyRelayoutOnSafeAreaChanges).to(beTrue()) 46 | expect(node.backgroundColor).to(equal(UIColor.white)) 47 | expect(node.delegate).toNot(beNil()) 48 | } 49 | 50 | it("should be didLoad success") { 51 | node.didLoad() 52 | expect(node.tableNode.view.separatorStyle == .none).to(beTrue()) 53 | expect(node.tableNode.view.showsVerticalScrollIndicator).to(beFalse()) 54 | expect(node.tableNode.view.showsHorizontalScrollIndicator).to(beFalse()) 55 | } 56 | 57 | it("should get controlNodes") { 58 | expect(node.delegate.getRegisterTypingControls()) 59 | .to(equal(self.controlAreaNode.typingControlNodes)) 60 | expect(node.delegate.dismissKeyboardNode()) 61 | .to(equal(self.controlAreaNode.dismissNode)) 62 | } 63 | 64 | it("shouldn't get controlNodes") { 65 | expect(nonControlAreaEditor.delegate?.getRegisterTypingControls()) 66 | .to(beNil()) 67 | expect(nonControlAreaEditor.delegate?.dismissKeyboardNode()) 68 | .to(beNil()) 69 | } 70 | } 71 | 72 | context("parse xmlString test") { 73 | 74 | beforeEach { 75 | node = VEditorNode 76 | .init(editorRule: self.rule, 77 | controlAreaNode: self.controlAreaNode) 78 | node.delegate = self 79 | node.didLoad() 80 | let path = Bundle.main.path(forResource: "content", ofType: "xml")! 81 | let pathURL = URL(fileURLWithPath: path) 82 | let data = try! Data(contentsOf: pathURL) 83 | let content = String(data: data, encoding: .utf8)! 84 | node.parseXMLString(content) 85 | } 86 | 87 | it("should be append new content") { 88 | expect(node.editorContents.count) 89 | .to(equal(9)) 90 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 91 | .to(equal(5)) 92 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 93 | .to(equal(2)) 94 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 95 | .to(equal(1)) 96 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 97 | .to(equal(1)) 98 | } 99 | } 100 | 101 | context("active text node test") { 102 | 103 | var textCellNodeIndexPaths: [IndexPath]! 104 | 105 | beforeEach { 106 | node = VEditorNode 107 | .init(editorRule: self.rule, 108 | controlAreaNode: self.controlAreaNode) 109 | node.delegate = self 110 | node.didLoad() 111 | let path = Bundle.main.path(forResource: "content", ofType: "xml")! 112 | let pathURL = URL(fileURLWithPath: path) 113 | let data = try! Data(contentsOf: pathURL) 114 | let content = String(data: data, encoding: .utf8)! 115 | node.parseXMLString(content) 116 | textCellNodeIndexPaths = node.editorContents 117 | .enumerated() 118 | .map({ index, content -> IndexPath? in 119 | if content is NSAttributedString { 120 | return IndexPath.init(row: index, section: 0) 121 | } else { 122 | return nil 123 | } 124 | }) 125 | .filter({ $0 != nil }) 126 | .map({ $0! }) 127 | } 128 | 129 | it("should get activeText") { 130 | // become first textNode activeStatus 131 | let cellNode = node.tableNode 132 | .nodeForRow(at: textCellNodeIndexPaths.first!) as! VEditorTextCellNode 133 | node.fetchNewActiveTextNode(cellNode) 134 | 135 | expect(node.activeTextIndexPath) 136 | .to(equal(textCellNodeIndexPaths.first!)) 137 | expect(node.loadActiveTextCellNode()).toNot(beNil()) 138 | 139 | // replace from first to last active textNode 140 | let cellNode2 = node.tableNode 141 | .nodeForRow(at: textCellNodeIndexPaths.last!) as! VEditorTextCellNode 142 | node.fetchNewActiveTextNode(cellNode2) 143 | 144 | expect(node.activeTextIndexPath) 145 | .to(equal(textCellNodeIndexPaths.last!)) 146 | expect(node.loadActiveTextCellNode()).toNot(beNil()) 147 | } 148 | 149 | it("should be resign activeText") { 150 | // become first textNode activeStatus 151 | let cellNode = node.tableNode 152 | .nodeForRow(at: textCellNodeIndexPaths.first!) as! VEditorTextCellNode 153 | node.fetchNewActiveTextNode(cellNode) 154 | 155 | expect(node.activeTextIndexPath) 156 | .to(equal(textCellNodeIndexPaths.first!)) 157 | expect(node.loadActiveTextCellNode()).toNot(beNil()) 158 | 159 | // resign activeTextNode 160 | node.resignActiveTextNode() 161 | expect(node.activeTextIndexPath).to(beNil()) 162 | expect(node.loadActiveTextCellNode()).to(beNil()) 163 | } 164 | } 165 | } 166 | 167 | describe("VEditorNode Fetch Content Unit Test") { 168 | 169 | var node: VEditorNode! 170 | 171 | context("append new content") { 172 | 173 | beforeEach { 174 | node = VEditorNode 175 | .init(editorRule: self.rule, 176 | controlAreaNode: self.controlAreaNode) 177 | node.delegate = self 178 | node.didLoad() 179 | let path = Bundle.main.path(forResource: "content", ofType: "xml")! 180 | let pathURL = URL(fileURLWithPath: path) 181 | let data = try! Data(contentsOf: pathURL) 182 | let content = String(data: data, encoding: .utf8)! 183 | node.parseXMLString(content) 184 | } 185 | 186 | it("should be append content at last") { 187 | expect(node.editorContents.count) 188 | .to(equal(9)) 189 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 190 | .to(equal(5)) 191 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 192 | .to(equal(2)) 193 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 194 | .to(equal(1)) 195 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 196 | .to(equal(1)) 197 | expect(node.editorContents.last is VImageContent).to(beFalse()) 198 | 199 | let mockContent = VImageContent.init("img", attributes: [:]) 200 | node.fetchNewContent(mockContent, scope: .last) 201 | 202 | expect(node.editorContents.count) 203 | .to(equal(11)) 204 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 205 | .to(equal(6)) 206 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 207 | .to(equal(2)) 208 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 209 | .to(equal(2)) 210 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 211 | .to(equal(1)) 212 | expect(node.editorContents.last is VImageContent).to(beFalse()) 213 | expect(node.editorContents[node.editorContents.count - 2] is VImageContent).to(beTrue()) 214 | } 215 | 216 | it("should be append contents at last") { 217 | expect(node.editorContents.count) 218 | .to(equal(9)) 219 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 220 | .to(equal(5)) 221 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 222 | .to(equal(2)) 223 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 224 | .to(equal(1)) 225 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 226 | .to(equal(1)) 227 | expect(node.editorContents.last is VVideoContent).to(beFalse()) 228 | 229 | let mockContent = VVideoContent.init("video", attributes: [:]) 230 | node.fetchNewContents([mockContent, mockContent, mockContent], scope: .last) 231 | 232 | expect(node.editorContents.count) 233 | .to(equal(13)) 234 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 235 | .to(equal(6)) 236 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 237 | .to(equal(5)) 238 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 239 | .to(equal(1)) 240 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 241 | .to(equal(1)) 242 | expect(node.editorContents.last is VVideoContent).to(beFalse()) 243 | expect(node.editorContents[node.editorContents.count - 2] is VVideoContent).to(beTrue()) 244 | } 245 | 246 | it("should be append content at fisrt") { 247 | expect(node.editorContents.count) 248 | .to(equal(9)) 249 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 250 | .to(equal(5)) 251 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 252 | .to(equal(2)) 253 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 254 | .to(equal(1)) 255 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 256 | .to(equal(1)) 257 | expect(node.editorContents.first is VImageContent).to(beFalse()) 258 | 259 | let mockContent = VImageContent.init("img", attributes: [:]) 260 | node.fetchNewContent(mockContent, scope: .first) 261 | 262 | expect(node.editorContents.count) 263 | .to(equal(10)) 264 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 265 | .to(equal(5)) 266 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 267 | .to(equal(2)) 268 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 269 | .to(equal(2)) 270 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 271 | .to(equal(1)) 272 | expect(node.editorContents.first is VImageContent).to(beTrue()) 273 | } 274 | 275 | it("should be append contents at first") { 276 | expect(node.editorContents.count) 277 | .to(equal(9)) 278 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 279 | .to(equal(5)) 280 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 281 | .to(equal(2)) 282 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 283 | .to(equal(1)) 284 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 285 | .to(equal(1)) 286 | expect(node.editorContents.first is VVideoContent).to(beFalse()) 287 | 288 | let mockContent = VVideoContent.init("video", attributes: [:]) 289 | node.fetchNewContents([mockContent, mockContent, mockContent], scope: .first) 290 | 291 | expect(node.editorContents.count) 292 | .to(equal(12)) 293 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 294 | .to(equal(5)) 295 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 296 | .to(equal(5)) 297 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 298 | .to(equal(1)) 299 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 300 | .to(equal(1)) 301 | expect(node.editorContents.first is VVideoContent).to(beTrue()) 302 | } 303 | 304 | it("should be insert content") { 305 | expect(node.editorContents.count) 306 | .to(equal(9)) 307 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 308 | .to(equal(5)) 309 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 310 | .to(equal(2)) 311 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 312 | .to(equal(1)) 313 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 314 | .to(equal(1)) 315 | expect(node.editorContents[1] is VVideoContent).to(beFalse()) 316 | 317 | let mockContent = VVideoContent.init("video", attributes: [:]) 318 | node.fetchNewContent(mockContent, scope: .insert(.init(row: 1, section: 0))) 319 | 320 | expect(node.editorContents.count) 321 | .to(equal(10)) 322 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 323 | .to(equal(5)) 324 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 325 | .to(equal(3)) 326 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 327 | .to(equal(1)) 328 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 329 | .to(equal(1)) 330 | expect(node.editorContents[1] is VVideoContent).to(beTrue()) 331 | } 332 | 333 | it("should be insert contents") { 334 | expect(node.editorContents.count) 335 | .to(equal(9)) 336 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 337 | .to(equal(5)) 338 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 339 | .to(equal(2)) 340 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 341 | .to(equal(1)) 342 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 343 | .to(equal(1)) 344 | expect(node.editorContents[1] is VVideoContent).to(beFalse()) 345 | expect(node.editorContents[2] is VVideoContent).to(beFalse()) 346 | 347 | let mockContent = VVideoContent.init("video", attributes: [:]) 348 | node.fetchNewContents([mockContent, mockContent], scope: .insert(.init(row: 1, section: 0))) 349 | 350 | expect(node.editorContents.count) 351 | .to(equal(11)) 352 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 353 | .to(equal(5)) 354 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 355 | .to(equal(4)) 356 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 357 | .to(equal(1)) 358 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 359 | .to(equal(1)) 360 | expect(node.editorContents[1] is VVideoContent).to(beTrue()) 361 | expect(node.editorContents[2] is VVideoContent).to(beTrue()) 362 | expect(node.editorContents[3] is VVideoContent).to(beFalse()) 363 | } 364 | 365 | 366 | it("should be insert mediaContent on text with split") { 367 | expect(node.editorContents.count) 368 | .to(equal(9)) 369 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 370 | .to(equal(5)) 371 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 372 | .to(equal(2)) 373 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 374 | .to(equal(1)) 375 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 376 | .to(equal(1)) 377 | 378 | let mockContent = VVideoContent.init("video", attributes: [:]) 379 | let targetNode = node.tableNode.nodeForRow(at: .init(row: 2, section: 0)) as! VEditorTextCellNode 380 | node.fetchNewActiveTextNode(targetNode) 381 | node.fetchNewContent(mockContent, scope: .automatic) 382 | 383 | expect(node.editorContents.count) 384 | .to(equal(10)) 385 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 386 | .to(equal(5)) 387 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 388 | .to(equal(3)) 389 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 390 | .to(equal(1)) 391 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 392 | .to(equal(1)) 393 | } 394 | 395 | it("should be insert mediaContent on text without split text") { 396 | expect(node.editorContents.count) 397 | .to(equal(9)) 398 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 399 | .to(equal(5)) 400 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 401 | .to(equal(2)) 402 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 403 | .to(equal(1)) 404 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 405 | .to(equal(1)) 406 | expect(node.editorContents.last is VVideoContent).to(beFalse()) 407 | 408 | let mockContent = VVideoContent.init("video", attributes: [:]) 409 | node.resignActiveTextNode() 410 | node.fetchNewContent(mockContent, scope: .automatic) 411 | 412 | expect(node.editorContents.count) 413 | .to(equal(11)) 414 | expect(node.editorContents.filter({ $0 is NSAttributedString }).count) 415 | .to(equal(6)) 416 | expect(node.editorContents.filter({ $0 is VVideoContent }).count) 417 | .to(equal(3)) 418 | expect(node.editorContents.filter({ $0 is VImageContent }).count) 419 | .to(equal(1)) 420 | expect(node.editorContents.filter({ $0 is VOpenGraphContent }).count) 421 | .to(equal(1)) 422 | expect(node.editorContents[node.editorContents.count - 2] is VVideoContent).to(beTrue()) 423 | expect(node.editorContents.last is VVideoContent).to(beFalse()) 424 | } 425 | } 426 | } 427 | } 428 | } 429 | 430 | extension VEditorNodeSpec: VEditorNodeDelegate { 431 | 432 | func getRegisterTypingControls() -> [VEditorTypingControlNode]? { 433 | return self.controlAreaNode.typingControlNodes 434 | } 435 | 436 | func dismissKeyboardNode() -> ASControlNode? { 437 | return self.controlAreaNode.dismissNode 438 | } 439 | 440 | func placeholderCellNode(_ content: VEditorPlaceholderContent, indexPath: IndexPath) -> VEditorMediaPlaceholderNode? { 441 | guard let xml = EditorRule.XML.init(rawValue: content.xmlTag) else { return nil } 442 | 443 | switch xml { 444 | case .article: 445 | guard let url = content.model as? URL else { return nil } 446 | return EditorOpenGraphPlaceholder(xmlTag: EditorRule.XML.opengraph.rawValue, 447 | url: url) 448 | default: 449 | break 450 | } 451 | return nil 452 | } 453 | 454 | func contentCellNode(_ content: VEditorContent, 455 | indexPath: IndexPath) -> ASCellNode? { 456 | switch content { 457 | case let text as NSAttributedString: 458 | return VEditorTextCellNode(isEdit: true, 459 | placeholderText: nil, 460 | attributedText: text, 461 | rule: self.rule, 462 | regexDelegate: nil, 463 | automaticallyGenerateLinkPreview: false) 464 | 465 | case is VImageContent: 466 | return VEditorImageNode(isEdit: true) 467 | 468 | case is VVideoContent: 469 | return VEditorVideoNode(isEdit: true) 470 | 471 | case is VOpenGraphContent: 472 | return VEditorOpenGraphNode(isEdit: true) 473 | default: 474 | return nil 475 | } 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /Example/Tests/VEditorParserSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorPArserSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import VEditorKit 12 | 13 | class VEditorParserSpec: QuickSpec { 14 | 15 | override func spec() { 16 | 17 | describe("VEditorParser Test") { 18 | 19 | let xmlString = "

helloworld!

LinkTest
" 20 | let parser = VEditorParser.init(rule: EditorRule.init()) 21 | 22 | context("Parsing should be success") { 23 | 24 | var resultError: Error? 25 | var resultContents: [VEditorContent]? 26 | 27 | beforeEach { 28 | parser.parseXML(xmlString, onSuccess: { contents in 29 | resultContents = contents 30 | }, onError: { error in 31 | resultError = error 32 | }) 33 | } 34 | 35 | it("should be parse success") { 36 | expect(resultError).to(beNil()) 37 | expect(resultContents?.count).to(equal(3)) 38 | expect(resultContents?[0]).to(beAKindOf(NSAttributedString.self)) 39 | expect(resultContents?[1]).to(beAKindOf(VImageContent.self)) 40 | expect(resultContents?[2]).to(beAKindOf(NSAttributedString.self)) 41 | } 42 | 43 | it("should be parse attributedString") { 44 | var pTagStyle = EditorRule.init().paragraphStyle("p", attributes: [:]) 45 | pTagStyle?.add(extraAttributes: [VEditorAttributeKey: ["p"]]) 46 | var bTagStyle = EditorRule.init().paragraphStyle("b", attributes: [:]) 47 | bTagStyle?.add(extraAttributes: [VEditorAttributeKey: ["b"]]) 48 | let expectedAttrText = NSMutableAttributedString.init() 49 | expectedAttrText.append("hello".styled(with: pTagStyle!)) 50 | expectedAttrText.append("world".styled(with: bTagStyle!)) 51 | expectedAttrText.append("!".styled(with: pTagStyle!)) 52 | 53 | expect(expectedAttrText == resultContents?.first as? NSAttributedString) 54 | .to(beTrue()) 55 | expect(expectedAttrText.string == (resultContents?.first as? NSAttributedString)?.string ?? "") 56 | .to(beTrue()) 57 | } 58 | 59 | it("should be parse image content") { 60 | expect((resultContents?[1] as? VImageContent)?.url?.absoluteString) 61 | .to(equal("http://image/12345.jpg")) 62 | expect((resultContents?[1] as? VImageContent)?.ratio).to(equal(0.5)) 63 | } 64 | 65 | it("should be parse link") { 66 | let url = (resultContents?.last as? NSAttributedString)?.attributes(at: 0, effectiveRange: nil)[NSAttributedString.Key.link] as? URL 67 | expect(url?.absoluteString ?? "").to(equal("https://www.vingle.net")) 68 | } 69 | 70 | it("should be parse to NSAttributedString Only") { 71 | expect(parser.parseXMLToAttributedString("

hello

").string == "hello") 72 | .to(beTrue()) 73 | } 74 | } 75 | 76 | context("HTML Entities handling test") { 77 | 78 | it("should be success to parse with HTML entities") { 79 | 80 | expect(parser.parseXMLToAttributedString("

hello & world

").string == "hello & world") 81 | .to(beTrue()) 82 | } 83 | 84 | it("should be fail to parse with HTML entities") { 85 | 86 | expect(parser.parseXMLToAttributedString("

hello & world

").string == "hello & world") 87 | .to(beFalse()) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Example/Tests/VEditorTextCellNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTextCellNodeSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import RxBlocking 13 | import VEditorKit 14 | 15 | class VEditorTextCellNodeSpec: QuickSpec { 16 | 17 | override func spec() { 18 | 19 | describe("VEditor TextCellNode Unit Test") { 20 | 21 | var node: VEditorTextCellNode! 22 | 23 | context("intialization test") { 24 | 25 | beforeEach { 26 | node = VEditorTextCellNode(isEdit: true, 27 | placeholderText: nil, 28 | attributedText: .init(string: "test"), 29 | rule: EditorRule()) 30 | } 31 | 32 | it("should be success") { 33 | expect(node.isEdit).to(beTrue()) 34 | expect(node.textNode).to(beAKindOf(VEditorTextNode.self)) 35 | expect(node.automaticallyManagesSubnodes).to(beTrue()) 36 | expect(node.insets).to(equal(.zero)) 37 | } 38 | } 39 | 40 | context("update layout test") { 41 | 42 | beforeEach { 43 | node = VEditorTextCellNode(isEdit: true, 44 | placeholderText: nil, 45 | attributedText: .init(string: "test"), 46 | rule: EditorRule()) 47 | } 48 | 49 | it("should be set default(zero) insets") { 50 | expect(node.insets).to(equal(.zero)) 51 | } 52 | 53 | it("should be update insets") { 54 | node.setContentInsets(.init(top: 50, left: 50, bottom: 50, right: 50)) 55 | expect(node.insets).to(equal(.init(top: 50, left: 50, bottom: 50, right: 50))) 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Example/Tests/VEditorTextNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTextNodeSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import AsyncDisplayKit 13 | import RxBlocking 14 | import VEditorKit 15 | 16 | class VEditorTextNodeSpec: QuickSpec { 17 | 18 | override func spec() { 19 | 20 | describe("VEditor TextNode Unit Test") { 21 | 22 | var content: NSAttributedString! 23 | var node: VEditorTextNode! 24 | let rule = EditorRule() 25 | 26 | beforeEach { 27 | let xmlString = "

abc

" 28 | VEditorParser.init(rule: rule).parseXML(xmlString, onSuccess: { contents in 29 | content = contents.first as? NSAttributedString 30 | }, onError: nil) 31 | } 32 | 33 | context("Initialization test") { 34 | 35 | beforeEach { 36 | node = VEditorTextNode(rule, 37 | isEdit: true, 38 | placeholderText: nil, 39 | attributedText: content) 40 | node.regexDelegate = self 41 | node.didLoad() 42 | } 43 | 44 | it("should be success") { 45 | expect(node.isEdit).to(beTrue()) 46 | expect(node.delegate).toNot(beNil()) 47 | expect(node.regexDelegate).toNot(beNil()) 48 | expect(node.automaticallyGenerateLinkPreview).to(beFalse()) 49 | expect(node.currentTypingAttribute.isEmpty).to(beFalse()) 50 | } 51 | 52 | it("should be begin editing") { 53 | expect(node.editableTextNodeShouldBeginEditing(node)).to(beTrue()) 54 | node.isEdit = false 55 | expect(node.editableTextNodeShouldBeginEditing(node)).to(beFalse()) 56 | node.isEdit = true 57 | } 58 | 59 | it("should be setup minimum text container line height") { 60 | expect(node.minimumTextContainerTextLineHeight()) 61 | .to(equal(((rule.defaultAttribute()[.paragraphStyle] as? NSParagraphStyle)?.minimumLineHeight ?? -1.0))) 62 | } 63 | } 64 | 65 | context("forceFetchCurrentLocationAttribute: test") { 66 | 67 | beforeEach { 68 | node = VEditorTextNode(rule, 69 | isEdit: true, 70 | placeholderText: nil, 71 | attributedText: content) 72 | node.regexDelegate = self 73 | node.didLoad() 74 | } 75 | 76 | it("should be get expected xmlTags") { 77 | node.selectedRange = .init(location: 0, length: 0) 78 | expect(node.forceFetchCurrentLocationAttribute()?.sorted()) 79 | .to(equal(["p"])) 80 | node.selectedRange = .init(location: 1, length: 0) 81 | expect(node.forceFetchCurrentLocationAttribute()?.sorted()) 82 | .to(equal(["p"])) 83 | node.selectedRange = .init(location: 2, length: 0) 84 | expect(node.forceFetchCurrentLocationAttribute()?.sorted()) 85 | .to(equal(["b"])) 86 | node.selectedRange = .init(location: 3, length: 0) 87 | expect(node.forceFetchCurrentLocationAttribute()?.sorted()) 88 | .to(equal(["b", "i"])) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | extension VEditorTextNodeSpec: VEditorRegexApplierDelegate { 96 | 97 | enum EditorTextRegexPattern: String, CaseIterable { 98 | 99 | case userTag = "@(\\w*[0-9A-Za-z])" 100 | case hashTag = "#(\\w*[0-9A-Za-zㄱ-ㅎ가-힣])" 101 | } 102 | 103 | var allPattern: [String] { 104 | return EditorTextRegexPattern.allCases.map({ $0.rawValue }) 105 | } 106 | 107 | func paragraphStyle(pattern: String) -> VEditorStyle? { 108 | guard let scope = EditorTextRegexPattern.init(rawValue: pattern) else { return nil } 109 | switch scope { 110 | case .userTag: 111 | return .init([.color(UIColor.init(red: 0.2, green: 0.8, blue: 0.2, alpha: 1.0))]) 112 | case .hashTag: 113 | return .init([.color(UIColor.init(red: 0.2, green: 0.3, blue: 0.8, alpha: 1.0))]) 114 | } 115 | } 116 | 117 | func handlePatternTouchEvent(_ pattern: String, value: Any) { 118 | // pass 119 | } 120 | 121 | func handlURLTouchEvent(_ url: URL) { 122 | // pass 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Example/Tests/VEditorTextStorageSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTextStorageSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import AsyncDisplayKit 13 | import RxBlocking 14 | import VEditorKit 15 | 16 | class VEditorTextStorageSpec: QuickSpec { 17 | 18 | override func spec() { 19 | 20 | describe("VEditorTextStorage Unit Test") { 21 | 22 | var storage: VEditorTextStorage! 23 | let rule = EditorRule() 24 | 25 | context("Intialization test") { 26 | 27 | beforeEach { 28 | storage = VEditorTextStorage.init() 29 | storage.setAttributedString(NSAttributedString(string: "test", 30 | attributes: rule.defaultAttribute())) 31 | } 32 | 33 | it("should be success initialize") { 34 | expect(storage.string).to(equal("test")) 35 | } 36 | } 37 | 38 | context("update property test") { 39 | 40 | beforeEach { 41 | storage = VEditorTextStorage.init() 42 | storage.setAttributedString(NSAttributedString(string: "test", 43 | attributes: rule.defaultAttribute())) 44 | } 45 | 46 | it("shold be success setAttribute") { 47 | storage.setAttributes(rule.linkAttribute(URL(string: "https://www.vingle.net")!), 48 | range: NSRange.init(location: 0, length: storage.length)) 49 | let url = storage.attributes(at: 0, effectiveRange: nil)[NSAttributedString.Key.link] as? URL 50 | expect(url?.absoluteString).to(equal("https://www.vingle.net")) 51 | } 52 | 53 | it("should be return paragraphBlockRange") { 54 | expect(storage.paragraphBlockRange(.init(location: 2, length: 1))) 55 | .to(equal(NSRange.init(location: 0, length: storage.length))) 56 | expect(storage.paragraphBlockRange(.init(location: 0, length: 2))) 57 | .to(equal(NSRange.init(location: 0, length: storage.length))) 58 | expect(storage.paragraphBlockRange(.init(location: 0, length: 0))) 59 | .to(equal(NSRange.init(location: 0, length: storage.length))) 60 | expect(storage.paragraphBlockRange(.init(location: 3, length: 0))) 61 | .to(equal(NSRange.init(location: 0, length: storage.length))) 62 | } 63 | } 64 | 65 | context("trigger touch event") { 66 | 67 | var content: NSAttributedString! 68 | var node: VEditorTextNode! 69 | var viewOnlyNode: VEditorTextNode! 70 | 71 | beforeEach { 72 | let xmlString = "

hello world @Geektree0101 https://www.vingle.net

" 73 | VEditorParser.init(rule: rule).parseXML(xmlString, onSuccess: { contents in 74 | content = contents.first as? NSAttributedString 75 | }, onError: nil) 76 | node = VEditorTextNode(rule, 77 | isEdit: true, 78 | placeholderText: nil, 79 | attributedText: content) 80 | node.regexDelegate = self 81 | 82 | viewOnlyNode = VEditorTextNode(rule, 83 | isEdit: false, 84 | placeholderText: nil, 85 | attributedText: content) 86 | viewOnlyNode.regexDelegate = self 87 | } 88 | 89 | it("should be trigger touch event [ViewOnly Avaliable]") { 90 | expect(viewOnlyNode.textStorage? 91 | .triggerTouchEventIfNeeds(viewOnlyNode, 92 | customRange: NSRange.init(location: 0, length: 0))) 93 | .to(beFalse()) 94 | expect(viewOnlyNode.textStorage? 95 | .triggerTouchEventIfNeeds(viewOnlyNode, 96 | customRange: NSRange.init(location: 15, length: 0))) 97 | .to(beTrue()) 98 | expect(viewOnlyNode.textStorage? 99 | .triggerTouchEventIfNeeds(viewOnlyNode, 100 | customRange: NSRange.init(location: 26, length: 0))) 101 | .to(beFalse()) 102 | expect(viewOnlyNode.textStorage? 103 | .triggerTouchEventIfNeeds(viewOnlyNode, 104 | customRange: NSRange.init(location: 32, length: 0))) 105 | .to(beTrue()) 106 | } 107 | 108 | it("shouldn't be trigger touch event") { 109 | expect(node.textStorage? 110 | .triggerTouchEventIfNeeds(node, 111 | customRange: NSRange.init(location: 0, length: 0))) 112 | .to(beFalse()) 113 | expect(node.textStorage? 114 | .triggerTouchEventIfNeeds(node, 115 | customRange: NSRange.init(location: 15, length: 0))) 116 | .to(beFalse()) 117 | expect(node.textStorage? 118 | .triggerTouchEventIfNeeds(node, 119 | customRange: NSRange.init(location: 26, length: 0))) 120 | .to(beFalse()) 121 | expect(node.textStorage? 122 | .triggerTouchEventIfNeeds(node, 123 | customRange: NSRange.init(location: 32, length: 0))) 124 | .to(beFalse()) 125 | } 126 | } 127 | 128 | context("replace attribute with regex patten match test") { 129 | 130 | var content: NSAttributedString! 131 | var node: VEditorTextNode! 132 | 133 | beforeEach { 134 | let xmlString = "

hello world @Geektree0101 https://www.vingle.net @Hello

" 135 | VEditorParser.init(rule: rule).parseXML(xmlString, onSuccess: { contents in 136 | content = contents.first as? NSAttributedString 137 | }, onError: nil) 138 | node = VEditorTextNode(rule, 139 | isEdit: true, 140 | placeholderText: nil, 141 | attributedText: content) 142 | } 143 | 144 | it("should be success replace attribute with regex pattern") { 145 | let fullRange = NSRange.init(location: 0, length: node.textStorage?.length ?? 0) 146 | 147 | expect(node.textStorage? 148 | .replaceAttributeWithRegexPattenIfNeeds(node, customRange: fullRange) ?? -1) 149 | .to(equal(0)) 150 | 151 | node.regexDelegate = self 152 | 153 | expect(node.textStorage? 154 | .replaceAttributeWithRegexPattenIfNeeds(node, customRange: fullRange) ?? -1) 155 | .to(equal(2)) 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | extension VEditorTextStorageSpec: VEditorRegexApplierDelegate { 163 | 164 | enum EditorTextRegexPattern: String, CaseIterable { 165 | 166 | case userTag = "@(\\w*[0-9A-Za-z])" 167 | case hashTag = "#(\\w*[0-9A-Za-zㄱ-ㅎ가-힣])" 168 | } 169 | 170 | var allPattern: [String] { 171 | return EditorTextRegexPattern.allCases.map({ $0.rawValue }) 172 | } 173 | 174 | func paragraphStyle(pattern: String) -> VEditorStyle? { 175 | guard let scope = EditorTextRegexPattern.init(rawValue: pattern) else { return nil } 176 | switch scope { 177 | case .userTag: 178 | return .init([.color(UIColor.init(red: 0.2, green: 0.8, blue: 0.2, alpha: 1.0))]) 179 | case .hashTag: 180 | return .init([.color(UIColor.init(red: 0.2, green: 0.3, blue: 0.8, alpha: 1.0))]) 181 | } 182 | } 183 | 184 | func handlePatternTouchEvent(_ pattern: String, value: Any) { 185 | // pass 186 | } 187 | 188 | func handlURLTouchEvent(_ url: URL) { 189 | // pass 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Example/Tests/VEditorTypingControlNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTypingControlNodeSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import RxBlocking 13 | import VEditorKit 14 | 15 | class VEditorTypingControlNodeSpec: QuickSpec { 16 | 17 | override func spec() { 18 | 19 | describe("VEditor Typing ControlNode Test") { 20 | 21 | var node: VEditorTypingControlNode! 22 | var touchableAttributeNode: VEditorTypingControlNode! 23 | var blockAttributeNode: VEditorTypingControlNode! 24 | 25 | context("intialization test") { 26 | 27 | beforeEach { 28 | node = VEditorTypingControlNode.init("p", rule: EditorRule()) 29 | touchableAttributeNode = VEditorTypingControlNode 30 | .init("a", rule: EditorRule(), isExternalHandler: true) 31 | blockAttributeNode = VEditorTypingControlNode 32 | .init("h2", rule: EditorRule(), isBlockStyle: true) 33 | } 34 | 35 | it("should be success") { 36 | expect(node.xmlTag).to(equal("p")) 37 | expect(node.typingStyle).toNot(beNil()) 38 | expect(node.isBlockStyle).to(beFalse()) 39 | expect(node.isExternalHandler).to(beFalse()) 40 | } 41 | 42 | it("should be success touchable attribute control node") { 43 | expect(touchableAttributeNode.isBlockStyle).to(beFalse()) 44 | expect(touchableAttributeNode.isExternalHandler).to(beTrue()) 45 | } 46 | 47 | it("should be success block attribute control node") { 48 | expect(blockAttributeNode.isBlockStyle).to(beTrue()) 49 | expect(blockAttributeNode.isExternalHandler).to(beFalse()) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Example/Tests/VEditorVideoNodeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorVideoNodeSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxTest 12 | import AsyncDisplayKit 13 | import RxBlocking 14 | import VEditorKit 15 | 16 | class VEditorVideoNodeSpec: QuickSpec { 17 | 18 | override func spec() { 19 | 20 | describe("VEditorVideoNode Unit Test") { 21 | 22 | var node: VEditorVideoNode! 23 | 24 | context("intialization test") { 25 | 26 | beforeEach { 27 | node = VEditorVideoNode.init(isEdit: true) 28 | } 29 | 30 | it("should be success") { 31 | expect(node.node).to(beAKindOf(ASVideoNode.self)) 32 | expect(node.automaticallyManagesSubnodes).to(beTrue()) 33 | expect(node.isEdit).to(beTrue()) 34 | } 35 | } 36 | 37 | context("update property test") { 38 | 39 | beforeEach { 40 | node = VEditorVideoNode.init(isEdit: true) 41 | } 42 | 43 | it("should be update previewImageURL") { 44 | expect(node.node.url).to(beNil()) 45 | node.setPreviewURL(URL(string: "https://raw.githubusercontent.com/GeekTree0101/VEditorKit/master/screenshots/intro.png")) 46 | expect(node.node.url).toNot(beNil()) 47 | } 48 | 49 | it("should be update assetURL") { 50 | expect(node.node.asset).to(beNil()) 51 | expect(node.node.assetURL).to(beNil()) 52 | node.setAssetURL(URL(string: "https://raw.githubusercontent.com/GeekTree0101/VEditorKit/master/screenshots/test2.mp4")) 53 | expect(node.assetURL).toNot(beNil()) 54 | expect(node.videoAsset).toNot(beNil()) 55 | expect(node.node.asset).to(beNil()) 56 | expect(node.node.assetURL).to(beNil()) 57 | node.didLoad() 58 | expect(node.node.asset).toNot(beNil()) 59 | expect(node.node.assetURL).toNot(beNil()) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Example/Tests/VEditorXMLBuilderSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorXMLBuilderSpec.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import VEditorKit 12 | 13 | class VEditorXMLBuilderSpec: QuickSpec { 14 | 15 | override func spec() { 16 | 17 | describe("VEditor XMLBuilder Test") { 18 | 19 | let rule = EditorRule.init() 20 | 21 | context("Build XML") { 22 | 23 | var testContents: [VEditorContent]! 24 | var testContents2: [VEditorContent]! 25 | var testContents3: [VEditorContent]! 26 | var testContents4: [VEditorContent]! 27 | var testContents5: [VEditorContent]! 28 | 29 | beforeEach { 30 | var pTagStyle = EditorRule.init().paragraphStyle("p", attributes: [:]) 31 | pTagStyle?.add(extraAttributes: [VEditorAttributeKey: ["p"]]) 32 | var bTagStyle = EditorRule.init().paragraphStyle("b", attributes: [:]) 33 | bTagStyle?.add(extraAttributes: [VEditorAttributeKey: ["b"]]) 34 | var boldItalicTagStyle = EditorRule.init().paragraphStyle("b", attributes: [:])? 35 | .byAdding(stringStyle: EditorRule.init().paragraphStyle("i", attributes: [:])!) 36 | boldItalicTagStyle?.add(extraAttributes: [VEditorAttributeKey: ["b", "i"]]) 37 | 38 | var headingTagStyle = EditorRule.init().paragraphStyle("h2", attributes: [:]) 39 | headingTagStyle?.add(extraAttributes: [VEditorAttributeKey: ["h2"]]) 40 | 41 | let textNode = NSMutableAttributedString.init() 42 | textNode.append("hello".styled(with: pTagStyle!)) 43 | textNode.append("world".styled(with: bTagStyle!)) 44 | textNode.append("!".styled(with: pTagStyle!)) 45 | 46 | 47 | let textNode2 = NSMutableAttributedString.init() 48 | textNode2.append("hello".styled(with: pTagStyle!)) 49 | textNode2.append("world".styled(with: bTagStyle!)) 50 | textNode2.append("!".styled(with: pTagStyle!)) 51 | textNode2.append("boldItalicTest".styled(with: boldItalicTagStyle!)) 52 | 53 | let imageNode = VImageContent.init("img", attributes: ["src": "https://test.jpg", "width": "540", "height": "810"]) 54 | 55 | 56 | let textNode3 = NSMutableAttributedString.init() 57 | textNode3.append("hello".styled(with: pTagStyle!)) 58 | textNode3.append("world".styled(with: bTagStyle!)) 59 | textNode3.append("!".styled(with: pTagStyle!)) 60 | textNode3.append("headingTest".styled(with: headingTagStyle!)) 61 | textNode3.append("boldItalicTest".styled(with: boldItalicTagStyle!)) 62 | 63 | testContents = [imageNode] 64 | testContents2 = [textNode] 65 | testContents3 = [textNode, imageNode] 66 | testContents4 = [textNode2] 67 | testContents5 = [textNode3] 68 | } 69 | 70 | it("shouldn't be nil after build to xmlString") { 71 | 72 | expect(VEditorXMLBuilder.shared.buildXML(testContents, rule: rule, packageTag: "content")).toNot(beNil()) 73 | expect(VEditorXMLBuilder.shared.buildXML(testContents2, rule: rule, packageTag: "content")).toNot(beNil()) 74 | expect(VEditorXMLBuilder.shared.buildXML(testContents3, rule: rule, packageTag: "content")).toNot(beNil()) 75 | expect(VEditorXMLBuilder.shared.buildXML(testContents4, rule: rule, packageTag: "content")).toNot(beNil()) 76 | 77 | expect(VEditorXMLBuilder.shared.buildXML(testContents, rule: rule, packageTag: nil)).toNot(beNil()) 78 | expect(VEditorXMLBuilder.shared.buildXML(testContents2, rule: rule, packageTag: nil)).toNot(beNil()) 79 | expect(VEditorXMLBuilder.shared.buildXML(testContents3, rule: rule, packageTag: nil)).toNot(beNil()) 80 | expect(VEditorXMLBuilder.shared.buildXML(testContents4, rule: rule, packageTag: nil)).toNot(beNil()) 81 | } 82 | 83 | it("should build expected xmlString") { 84 | 85 | expect(VEditorXMLBuilder.shared.buildXML(testContents2, rule: rule, packageTag: "content")) 86 | .to(equal("

helloworld!

")) 87 | 88 | expect(VEditorXMLBuilder.shared.buildXML(testContents4, rule: rule, packageTag: "content")) 89 | .to(equal("

helloworld!boldItalicTest

")) 90 | 91 | expect(VEditorXMLBuilder.shared.buildXML(testContents5, rule: rule, packageTag: "content")) 92 | .to(equal("

helloworld!

headingTest

boldItalicTest

")) 93 | } 94 | } 95 | 96 | context("HTML Entities handling test") { 97 | 98 | var testContents: [VEditorContent]! 99 | 100 | beforeEach { 101 | var pTagStyle = EditorRule.init().paragraphStyle("p", attributes: [:]) 102 | pTagStyle?.add(extraAttributes: [VEditorAttributeKey: ["p"]]) 103 | let textNode = NSMutableAttributedString.init() 104 | textNode.append("hello & world".styled(with: pTagStyle!)) 105 | testContents = [textNode] 106 | VEditorXMLBuilder.shared.encodingHTMLEntitiesExternalHandler = nil 107 | } 108 | 109 | it("should be success to build with default HTML entities") { 110 | expect(VEditorXMLBuilder.shared.encodingHTMLEntitiesExternalHandler) 111 | .to(beNil()) 112 | expect(VEditorXMLBuilder.shared.buildXML(testContents, rule: rule, packageTag: "content")) 113 | .to(equal("

hello & world

")) 114 | } 115 | 116 | it("should be success to build with custom HTML entities") { 117 | VEditorXMLBuilder.shared.encodingHTMLEntitiesExternalHandler = { text -> String in 118 | return text.replacingOccurrences(of: "&", with: "&") 119 | } 120 | 121 | expect(VEditorXMLBuilder.shared.encodingHTMLEntitiesExternalHandler) 122 | .toNot(beNil()) 123 | expect(VEditorXMLBuilder.shared.buildXML(testContents, rule: rule, packageTag: "content")) 124 | .to(equal("

hello & world

")) 125 | } 126 | 127 | it("should be fail to build with custom HTML entities") { 128 | 129 | VEditorXMLBuilder.shared.encodingHTMLEntitiesExternalHandler = { text -> String in 130 | return text 131 | } 132 | 133 | expect(VEditorXMLBuilder.shared.encodingHTMLEntitiesExternalHandler) 134 | .toNot(beNil()) 135 | expect(VEditorXMLBuilder.shared.buildXML(testContents, rule: rule, packageTag: "content")) 136 | .toNot(equal("

hello & world

")) 137 | } 138 | } 139 | } 140 | 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Example/VEditorKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/VEditorKit.xcodeproj/xcshareddata/xcschemes/VEditorKit-Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 79 | 81 | 87 | 88 | 89 | 90 | 91 | 92 | 98 | 100 | 106 | 107 | 108 | 109 | 111 | 112 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /Example/VEditorKit.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/VEditorKit.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/VEditorKit/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | func application(_ application: UIApplication, 17 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | window = UIWindow(frame: UIScreen.main.bounds) // create UIwindow 19 | if let window = window { 20 | let navController = UINavigationController.init(rootViewController: EditorNodeController()) 21 | window.rootViewController = navController 22 | window.makeKeyAndVisible() 23 | } 24 | 25 | return true 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Example/VEditorKit/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Example/VEditorKit/Controllers/EditorNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorNodeController.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | import VEditorKit 11 | import RxCocoa 12 | import RxSwift 13 | import Photos 14 | import MobileCoreServices 15 | 16 | class EditorNodeController: ASViewController { 17 | 18 | struct Const { 19 | static let deleteIcon = UIImage.init(named: "cancel")?.withColor(.white) 20 | static let defaultContentInsets: UIEdgeInsets = 21 | .init(top: 15.0, left: 5.0, bottom: 15.0, right: 5.0) 22 | static let ogObjectContainerInsets: UIEdgeInsets = 23 | .init(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0) 24 | static let placeholderTextStyle: VEditorStyle = .init([.font(UIFont.systemFont(ofSize: 16)), 25 | .minimumLineHeight(24.0), 26 | .color(.lightGray)]) 27 | } 28 | let controlAreaNode: EditorControlAreaNode 29 | let disposeBag = DisposeBag() 30 | let isEditMode: Bool 31 | let xmlString: String? 32 | 33 | init(isEditMode: Bool = true, xmlString: String? = nil) { 34 | let rule = EditorRule() 35 | self.controlAreaNode = EditorControlAreaNode(rule: rule) 36 | self.isEditMode = isEditMode 37 | self.xmlString = xmlString 38 | super.init(node: .init(editorRule: rule, controlAreaNode: controlAreaNode)) 39 | self.title = isEditMode ? "Editor": "Preview" 40 | self.node.delegate = self 41 | self.controlAreaNode.isHidden = !isEditMode 42 | } 43 | 44 | override func viewDidLoad() { 45 | super.viewDidLoad() 46 | self.setupNavigationBarButtonItem() 47 | self.loadXMLContent() 48 | self.rxAlbumAccess() 49 | self.rxLinkInsert() 50 | } 51 | 52 | required init?(coder aDecoder: NSCoder) { 53 | fatalError("init(coder:) has not been implemented") 54 | } 55 | } 56 | 57 | extension EditorNodeController: VEditorNodeDelegate { 58 | 59 | func getRegisterTypingControls() -> [VEditorTypingControlNode]? { 60 | return controlAreaNode.typingControlNodes 61 | } 62 | 63 | func dismissKeyboardNode() -> ASControlNode? { 64 | return controlAreaNode.dismissNode 65 | } 66 | 67 | func placeholderCellNode(_ content: VEditorPlaceholderContent, indexPath: IndexPath) -> VEditorMediaPlaceholderNode? { 68 | guard let xml = EditorRule.XML.init(rawValue: content.xmlTag) else { return nil } 69 | 70 | switch xml { 71 | case .article: 72 | guard let url = content.model as? URL else { return nil } 73 | return EditorOpenGraphPlaceholder(xmlTag: EditorRule.XML.opengraph.rawValue, 74 | url: url) 75 | default: 76 | break 77 | } 78 | return nil 79 | } 80 | 81 | func contentCellNode(_ content: VEditorContent, indexPath: IndexPath) -> ASCellNode? { 82 | switch content { 83 | case let text as NSAttributedString: 84 | let placeholderText: NSAttributedString? = 85 | indexPath.row == 0 ? "Insert text...".styled(with: Const.placeholderTextStyle): nil 86 | return VEditorTextCellNode(isEdit: isEditMode, 87 | placeholderText: placeholderText, 88 | attributedText: text, 89 | rule: self.node.editorRule, 90 | regexDelegate: self, 91 | automaticallyGenerateLinkPreview: true) 92 | .setContentInsets(Const.defaultContentInsets) 93 | 94 | case let imageNode as VImageContent: 95 | return VEditorImageNode(isEdit: isEditMode, 96 | deleteNode: .init(iconImage: Const.deleteIcon)) 97 | .setContentInsets(Const.defaultContentInsets) 98 | .setTextInsertionHeight(16.0) 99 | .setURL(imageNode.url) 100 | .setMediaRatio(imageNode.ratio) 101 | .setPlaceholderColor(.lightGray) 102 | .setBackgroundColor(.lightGray) 103 | 104 | case let videoNode as VVideoContent: 105 | return VEditorVideoNode(isEdit: isEditMode, 106 | deleteNode: .init(iconImage: Const.deleteIcon)) 107 | .setContentInsets(Const.defaultContentInsets) 108 | .setTextInsertionHeight(16.0) 109 | .setAssetURL(videoNode.url) 110 | .setPreviewURL(videoNode.posterURL) 111 | .setMediaRatio(videoNode.ratio) 112 | .setPlaceholderColor(.lightGray) 113 | .setBackgroundColor(.black) 114 | 115 | case let ogObjectNode as VOpenGraphContent: 116 | // custom media content example 117 | let cellNode = VEditorOpenGraphNode(isEdit: isEditMode, 118 | deleteNode: .init(iconImage: Const.deleteIcon)) 119 | .setContentInsets(Const.ogObjectContainerInsets) 120 | .setContainerInsets(Const.ogObjectContainerInsets) 121 | .setPreviewImageURL(ogObjectNode.posterURL) 122 | .setPreviewImageSize(.init(width: 100.0, height: 100.0), cornerRadius: 5.0) 123 | .setTitleAttribute(ogObjectNode.title, 124 | attrStyle: .init([.font(UIFont.systemFont(ofSize: 16, weight: .bold)), 125 | .minimumLineHeight(25.0), 126 | .color(.black)])) 127 | .setDescAttribute(ogObjectNode.desc, 128 | attrStyle: .init([.font(UIFont.systemFont(ofSize: 13)), 129 | .minimumLineHeight(22.0), 130 | .color(.black)])) 131 | .setSourceAttribute(ogObjectNode.url, 132 | attrStyle: .init([.font(UIFont.systemFont(ofSize: 12)), 133 | .minimumLineHeight(21.0), 134 | .color(.gray), 135 | .underline(.single, .gray)])) 136 | 137 | cellNode.rx.didTapDelete 138 | .bind(to: self.node.rx.deleteContent(animated: true)) 139 | .disposed(by: cellNode.disposeBag) 140 | 141 | return cellNode 142 | default: 143 | return nil 144 | } 145 | } 146 | } 147 | 148 | extension EditorNodeController: VEditorRegexApplierDelegate { 149 | 150 | enum EditorTextRegexPattern: String, CaseIterable { 151 | 152 | case userTag = "@(\\w*[0-9A-Za-z])" 153 | case hashTag = "#(\\w*[0-9A-Za-zㄱ-ㅎ가-힣])" 154 | } 155 | 156 | var allPattern: [String] { 157 | return EditorTextRegexPattern.allCases.map({ $0.rawValue }) 158 | } 159 | 160 | func paragraphStyle(pattern: String) -> VEditorStyle? { 161 | guard let scope = EditorTextRegexPattern.init(rawValue: pattern) else { return nil } 162 | switch scope { 163 | case .userTag: 164 | return .init([.color(UIColor.init(red: 0.2, green: 0.8, blue: 0.2, alpha: 1.0))]) 165 | case .hashTag: 166 | return .init([.color(UIColor.init(red: 0.2, green: 0.3, blue: 0.8, alpha: 1.0))]) 167 | } 168 | } 169 | 170 | func handlePatternTouchEvent(_ pattern: String, value: Any) { 171 | guard let scope = EditorTextRegexPattern.init(rawValue: pattern) else { return } 172 | switch scope { 173 | case .userTag: 174 | guard let username = value as? String else { return } 175 | let toast = UIAlertController(title: "You did tap username: \(username)", 176 | message: nil, 177 | preferredStyle: .alert) 178 | toast.addAction(.init(title: "OK", style: .cancel, handler: nil)) 179 | self.present(toast, animated: true, completion: nil) 180 | case .hashTag: 181 | guard let tag = value as? String else { return } 182 | let toast = UIAlertController(title: "You did tap hashTag: \(tag)", 183 | message: nil, 184 | preferredStyle: .alert) 185 | toast.addAction(.init(title: "OK", style: .cancel, handler: nil)) 186 | self.present(toast, animated: true, completion: nil) 187 | } 188 | } 189 | 190 | func handlURLTouchEvent(_ url: URL) { 191 | UIApplication.shared.openURL(url) 192 | } 193 | } 194 | 195 | extension EditorNodeController { 196 | 197 | private func loadXMLContent() { 198 | if let content = self.xmlString { 199 | self.node.parseXMLString(content) 200 | } else { 201 | guard let path = Bundle.main.path(forResource: "content", ofType: "xml"), 202 | case let pathURL = URL(fileURLWithPath: path), 203 | let data = try? Data(contentsOf: pathURL), 204 | let content = String(data: data, encoding: .utf8) else { return } 205 | 206 | self.node.parseXMLString(content) 207 | } 208 | } 209 | 210 | private func setupNavigationBarButtonItem() { 211 | guard self.isEditMode else { return } 212 | let xmlBuildItem = UIBarButtonItem.init(title: "XML", 213 | style: .plain, 214 | target: self, 215 | action: #selector(pushXMLViewer)) 216 | let previewItem = UIBarButtonItem.init(title: "Preview", 217 | style: .plain, 218 | target: self, 219 | action: #selector(previewViewer)) 220 | self.navigationItem.rightBarButtonItems = [xmlBuildItem, previewItem] 221 | } 222 | 223 | @objc func pushXMLViewer() { 224 | self.node.synchronizeFetchContents() 225 | guard let output = self.node.buildXML(packageTag: "content") else { return } 226 | let vc = XMLViewController.init(output) 227 | self.navigationController?.pushViewController(vc, animated: true) 228 | } 229 | 230 | @objc func previewViewer() { 231 | self.node.synchronizeFetchContents() 232 | let xmlString = self.node.buildXML(packageTag: "content") 233 | let vc = EditorNodeController(isEditMode: false, xmlString: xmlString) 234 | self.navigationController?.pushViewController(vc, animated: true) 235 | } 236 | } 237 | 238 | extension EditorNodeController { 239 | 240 | private func rxLinkInsert() { 241 | self.controlAreaNode.linkInsertNode 242 | .addTarget(self, 243 | action: #selector(didTapLinkInsert), 244 | forControlEvents: .touchUpInside) 245 | } 246 | 247 | @objc private func didTapLinkInsert() { 248 | let vc = UIAlertController.init(title: "Link Insert", message: nil, preferredStyle: .alert) 249 | vc.addTextField(configurationHandler: { field in 250 | field.placeholder = "Link Insert..." 251 | return 252 | }) 253 | vc.addAction(.init(title: "Confirm", style: .default, handler: { [weak self] action in 254 | guard let `self` = self else { return } 255 | 256 | guard let field: UITextField = vc.textFields?.first, 257 | let text = field.text, 258 | !text.isEmpty, 259 | let url = URL(string: text) else { return } 260 | 261 | self.node.insertLinkOnActiveTextSelectedRange(url) 262 | vc.dismiss(animated: true, completion: nil) 263 | })) 264 | vc.addAction(.init(title: "Cancel", style: .cancel, handler: nil)) 265 | 266 | self.present(vc, animated: true, completion: nil) 267 | } 268 | } 269 | 270 | extension EditorNodeController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { 271 | 272 | private func rxAlbumAccess() { 273 | 274 | self.controlAreaNode.photoLoadControlNode 275 | .addTarget(self, 276 | action: #selector(didTapPhotoAlbumControl), 277 | forControlEvents: .touchUpInside) 278 | 279 | self.controlAreaNode.videoLoadControlNode 280 | .addTarget(self, 281 | action: #selector(didTapVideoAlbumControl), 282 | forControlEvents: .touchUpInside) 283 | } 284 | 285 | @objc private func didTapPhotoAlbumControl() { 286 | self.openAlbum(.imageOnly) 287 | } 288 | 289 | @objc private func didTapVideoAlbumControl() { 290 | self.openAlbum(.videoOnly) 291 | } 292 | 293 | enum MeidaScope { 294 | 295 | case imageOnly 296 | case videoOnly 297 | 298 | var value: String { 299 | switch self { 300 | case .imageOnly: 301 | return kUTTypeImage as String 302 | case .videoOnly: 303 | return kUTTypeMovie as String 304 | } 305 | } 306 | } 307 | 308 | private func openAlbum(_ type: MeidaScope) { 309 | let imagePickerController = UIImagePickerController() 310 | imagePickerController.delegate = self 311 | imagePickerController.sourceType = .photoLibrary 312 | imagePickerController.mediaTypes = [type.value] 313 | imagePickerController.allowsEditing = false 314 | self.present(imagePickerController, animated: true, completion: nil) 315 | } 316 | 317 | func imagePickerController(_ picker: UIImagePickerController, 318 | didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { 319 | 320 | if let videoURL = info[.mediaURL] as? URL, 321 | case let asset = AVAsset.init(url: videoURL), 322 | let size = asset.tracks.map({ $0.naturalSize }).filter({ $0 != .zero }).first { 323 | 324 | let content = VVideoContent.init(EditorRule.XML.video.rawValue, attributes: [:]) 325 | content.url = videoURL 326 | content.height = size.height 327 | content.width = size.width 328 | picker.dismiss(animated: true, completion: { 329 | self.node.fetchNewContent(content, scope: .automatic) 330 | }) 331 | return 332 | } 333 | 334 | if #available(iOS 11.0, *) { 335 | if let imageURL = info[.imageURL] as? URL, 336 | let imageData = try? Data(contentsOf: imageURL, options: []), 337 | let image = UIImage(data: imageData) { 338 | let imageSize = image.size 339 | 340 | let content = VImageContent.init(EditorRule.XML.image.rawValue, attributes: [:]) 341 | content.url = imageURL 342 | content.height = imageSize.height 343 | content.width = imageSize.width 344 | picker.dismiss(animated: true, completion: { 345 | self.node.fetchNewContent(content, scope: .automatic) 346 | }) 347 | return 348 | } 349 | } else { 350 | // :( i don't care 351 | picker.dismiss(animated: true, completion: nil) 352 | } 353 | 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /Example/VEditorKit/Controllers/XMLViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XMLViewController.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import VEditorKit 12 | 13 | class XMLViewController: ASViewController { 14 | 15 | lazy var textNode = ASTextNode() 16 | 17 | init(_ xmlString: String) { 18 | super.init(node: .init()) 19 | self.title = "XML Viewer" 20 | self.node.automaticallyManagesContentSize = true 21 | self.node.automaticallyManagesSubnodes = true 22 | self.node.automaticallyRelayoutOnSafeAreaChanges = true 23 | self.node.backgroundColor = .white 24 | self.node.layoutSpecBlock = { [weak self] (_, sizeRange) -> ASLayoutSpec in 25 | return self?.layoutSpecThatFits(sizeRange) ?? ASLayoutSpec() 26 | } 27 | textNode.attributedText = xmlString.replacingOccurrences(of: "\n", with: "\\n").styled(with: .font(UIFont.systemFont(ofSize: 15.0))) 28 | } 29 | 30 | required init?(coder aDecoder: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | 34 | override func viewDidAppear(_ animated: Bool) { 35 | super.viewDidAppear(animated) 36 | self.node.view.setContentOffset(.init(x: 0.0, y: -self.node.safeAreaInsets.top), 37 | animated: true) 38 | } 39 | 40 | private func layoutSpecThatFits(_ constraintedSize: ASSizeRange) -> ASLayoutSpec { 41 | var insets: UIEdgeInsets = self.node.safeAreaInsets 42 | insets.left += 10.0 43 | insets.right += 10.0 44 | insets.bottom += 10.0 45 | insets.top += 20.0 46 | return ASInsetLayoutSpec(insets: insets, child: textNode) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Example/VEditorKit/EditorServices/EditorRule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorRule.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import VEditorKit 11 | 12 | struct EditorRule: VEditorRule { 13 | 14 | enum XML: String, CaseIterable { 15 | 16 | case article = "a" 17 | case paragraph = "p" 18 | case bold = "b" 19 | case italic = "i" 20 | case heading = "h2" 21 | case quote = "blockquote" 22 | case image = "img" 23 | case video = "video" 24 | case opengraph = "og-object" 25 | } 26 | 27 | var defaultStyleXMLTag: String { 28 | return XML.paragraph.rawValue 29 | } 30 | 31 | var linkStyleXMLTag: String? { 32 | return XML.article.rawValue 33 | } 34 | 35 | var blockStyleXMLTags: [String] { 36 | return [XML.heading, XML.quote].map({ $0.rawValue }) 37 | } 38 | 39 | var allXML: [String] { 40 | return XML.allCases.map({ $0.rawValue }) 41 | } 42 | 43 | func paragraphStyle(_ xmlTag: String, attributes: [String : String]) -> VEditorStyle? { 44 | guard let xml = XML.init(rawValue: xmlTag) else { return nil } 45 | 46 | switch xml { 47 | case .paragraph: 48 | return .init([.font(UIFont.systemFont(ofSize: 16)), 49 | .minimumLineHeight(26.0), 50 | .color(.black)]) 51 | case .bold: 52 | return .init([.emphasis(.bold), 53 | .font(UIFont.systemFont(ofSize: 16)), 54 | .minimumLineHeight(26.0), 55 | .color(.black)]) 56 | case .italic: 57 | return .init([.emphasis(.italic), 58 | .font(UIFont.systemFont(ofSize: 16)), 59 | .minimumLineHeight(26.0), 60 | .color(.black)]) 61 | case .heading: 62 | return .init([.font(UIFont.systemFont(ofSize: 30, weight: .medium)), 63 | .minimumLineHeight(40.0), 64 | .color(.black)]) 65 | case .quote: 66 | return .init([.font(UIFont.systemFont(ofSize: 20)), 67 | .color(.gray), 68 | .firstLineHeadIndent(19.0), 69 | .minimumLineHeight(30.0), 70 | .headIndent(19.0)]) 71 | case .article: 72 | let style: VEditorStyle = .init([.font(UIFont.systemFont(ofSize: 16)), 73 | .minimumLineHeight(26.0), 74 | .color(.black), 75 | .underline(.single, .black)]) 76 | if let url = URL(string: attributes["href"] ?? "") { 77 | return style.byAdding([.link(url)]) 78 | } else { 79 | return style 80 | } 81 | 82 | default: 83 | return nil 84 | } 85 | } 86 | 87 | func build(_ xmlTag: String, attributes: [String : String]) -> VEditorMediaContent? { 88 | guard let xml = XML.init(rawValue: xmlTag) else { return nil } 89 | 90 | switch xml { 91 | case .image: 92 | return VImageContent(xmlTag, attributes: attributes) 93 | case .video: 94 | return VVideoContent(xmlTag, attributes: attributes) 95 | case .opengraph: 96 | return VOpenGraphContent(xmlTag, attributes: attributes) 97 | default: 98 | return nil 99 | } 100 | } 101 | 102 | func parseAttributeToXML(_ xmlTag: String, 103 | attributes: [NSAttributedString.Key : Any]) -> [String : String]? { 104 | guard let xml = XML.init(rawValue: xmlTag) else { return nil } 105 | 106 | switch xml { 107 | case .article: 108 | if let url = attributes[.link] as? URL, 109 | case let urlString = url.absoluteString { 110 | return ["href": urlString] 111 | } else { 112 | return nil 113 | } 114 | default: 115 | return nil 116 | } 117 | } 118 | 119 | func enableTypingXMLs(_ inActiveXML: String) -> [String]? { 120 | guard let xml = XML.init(rawValue: inActiveXML) else { return nil } 121 | 122 | switch xml { 123 | case .heading, .quote: 124 | return [XML.bold.rawValue, 125 | XML.italic.rawValue, 126 | XML.paragraph.rawValue] 127 | default: 128 | return nil 129 | } 130 | } 131 | 132 | func disableTypingXMLs(_ activeXML: String) -> [String]? { 133 | guard let xml = XML.init(rawValue: activeXML) else { return nil } 134 | 135 | switch xml { 136 | case .heading, .quote: 137 | return [XML.bold.rawValue, 138 | XML.italic.rawValue, 139 | XML.paragraph.rawValue] 140 | default: 141 | return nil 142 | } 143 | } 144 | 145 | func inactiveTypingXMLs(_ activeXML: String) -> [String]? { 146 | guard let xml = XML.init(rawValue: activeXML) else { return nil } 147 | 148 | switch xml { 149 | case .heading: 150 | return [XML.quote.rawValue] 151 | case .quote: 152 | return [XML.heading.rawValue] 153 | default: 154 | return nil 155 | } 156 | } 157 | 158 | func activeTypingXMLs(_ inactiveXML: String) -> [String]? { 159 | return nil 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Example/VEditorKit/EditorServices/VImageContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VImageContent.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import VEditorKit 11 | 12 | class VImageContent: VEditorMediaContent { 13 | 14 | var xmlTag: String 15 | 16 | var url: URL? 17 | var width: CGFloat 18 | var height: CGFloat 19 | 20 | var ratio: CGFloat { 21 | return height / width 22 | } 23 | 24 | required init(_ xmlTag: String, attributes: [String : String]) { 25 | self.xmlTag = xmlTag 26 | self.url = URL(string: attributes["src"] ?? "") 27 | self.width = CGFloat(Int(attributes["width"] ?? "") ?? 1) 28 | self.height = CGFloat(Int(attributes["height"] ?? "") ?? 1) 29 | } 30 | 31 | func parseAttributeToXML() -> [String : String] { 32 | return ["src": url?.absoluteString ?? "", 33 | "width": "\(Int(width))", 34 | "height": "\(Int(height))"] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Example/VEditorKit/EditorServices/VOpenGraphContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VOpenGraphContent.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import VEditorKit 11 | 12 | class VOpenGraphContent: VEditorMediaContent { 13 | 14 | var xmlTag: String 15 | 16 | var title: String? 17 | var desc: String? 18 | var url: URL? 19 | var posterURL: URL? 20 | 21 | required init(_ xmlTag: String, attributes: [String : String]) { 22 | self.xmlTag = xmlTag 23 | self.title = attributes["title"] 24 | self.desc = attributes["description"] 25 | self.url = URL(string: attributes["url"] ?? "") 26 | self.posterURL = URL(string: attributes["image"] ?? "") 27 | } 28 | 29 | func parseAttributeToXML() -> [String : String] { 30 | return ["title": self.title ?? "", 31 | "desc": self.desc ?? "", 32 | "url": url?.absoluteString ?? "", 33 | "image": posterURL?.absoluteString ?? ""] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Example/VEditorKit/EditorServices/VVideoContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VVideoContent.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import VEditorKit 11 | 12 | class VVideoContent: VEditorMediaContent { 13 | 14 | var xmlTag: String 15 | 16 | var url: URL? 17 | var posterURL: URL? 18 | var width: CGFloat 19 | var height: CGFloat 20 | 21 | var ratio: CGFloat { 22 | return height / width 23 | } 24 | 25 | required init(_ xmlTag: String, attributes: [String : String]) { 26 | self.xmlTag = xmlTag 27 | self.url = URL(string: attributes["src"] ?? "") 28 | self.width = CGFloat(Int(attributes["width"] ?? "") ?? 1) 29 | self.height = CGFloat(Int(attributes["height"] ?? "") ?? 1) 30 | self.posterURL = URL(string: attributes["poster"] ?? "") 31 | } 32 | 33 | func parseAttributeToXML() -> [String : String] { 34 | return ["src": url?.absoluteString ?? "", 35 | "poster": posterURL?.absoluteString ?? "", 36 | "width": "\(Int(width))", 37 | "height": "\(Int(height))"] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/VEditorKit/Extensions/UIImage+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Extension.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIImage { 13 | 14 | func withColor(_ color: UIColor) -> UIImage { 15 | UIGraphicsBeginImageContextWithOptions(size, false, scale) 16 | guard let ctx = UIGraphicsGetCurrentContext(), let cgImage = cgImage else { return self } 17 | color.setFill() 18 | ctx.translateBy(x: 0, y: size.height) 19 | ctx.scaleBy(x: 1.0, y: -1.0) 20 | ctx.clip(to: CGRect(x: 0, y: 0, width: size.width, height: size.height), mask: cgImage) 21 | ctx.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height)) 22 | guard let colored = UIGraphicsGetImageFromCurrentImageContext() else { return self } 23 | UIGraphicsEndImageContext() 24 | return colored 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ios-marketing", 45 | "size" : "1024x1024", 46 | "scale" : "1x" 47 | } 48 | ], 49 | "info" : { 50 | "version" : 1, 51 | "author" : "xcode" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/cancel.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "cancel.png" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/cancel.imageset/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/cancel.imageset/cancel.png -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/cancel.png -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image.png" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/image.imageset/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/image.imageset/image.png -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/image.png -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/keyboard.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "keyboard.png" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/keyboard.imageset/keyboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/keyboard.imageset/keyboard.png -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/keyboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/keyboard.png -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/license.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/license.pdf -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/video.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "video.png" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/video.imageset/video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/video.imageset/video.png -------------------------------------------------------------------------------- /Example/VEditorKit/Images.xcassets/video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/Example/VEditorKit/Images.xcassets/video.png -------------------------------------------------------------------------------- /Example/VEditorKit/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | NSCameraUsageDescription 26 | This app needs access to your photo library so you can upload photos. 27 | NSMicrophoneUsageDescription 28 | This app needs access to your photo library so you can upload photos. 29 | NSPhotoLibraryAddUsageDescription 30 | This app needs access to your photo library so you can upload photos. 31 | NSPhotoLibraryUsageDescription 32 | This app needs access to your photo library so you can upload photos. 33 | UILaunchStoryboardName 34 | LaunchScreen 35 | UIRequiredDeviceCapabilities 36 | 37 | armv7 38 | 39 | UISupportedInterfaceOrientations 40 | 41 | UIInterfaceOrientationPortrait 42 | UIInterfaceOrientationLandscapeLeft 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Example/VEditorKit/Services/MockService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockService.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | import RxCocoa 12 | import VEditorKit 13 | 14 | struct MockService { 15 | 16 | static func getOgObject(_ url: URL) -> Observable<[String: String]> { 17 | return Observable.just(["title": "test title", 18 | "desc": "test description", 19 | "url": url.absoluteString, 20 | "image": "https://cdn-images-1.medium.com/max/1600/0*XNcfCZEJrsXenM9c.jpg"]) 21 | .delay(2.0, scheduler: MainScheduler.instance) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Example/VEditorKit/Views/EditorControlAreaNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorControlAreaNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import VEditorKit 12 | 13 | class EditorControlAreaNode: ASDisplayNode { 14 | 15 | struct Const { 16 | static let insets: UIEdgeInsets = .init(top: 10.0, left: 5.0, bottom: 5.0, right: 10.0) 17 | static let controlSize: CGSize = .init(width: 44.0, height: 44.0) 18 | } 19 | 20 | lazy var boldNode: VEditorTypingControlNode = { 21 | let node = VEditorTypingControlNode(EditorRule.XML.bold.rawValue, 22 | rule: rule) 23 | node.style.preferredSize = Const.controlSize 24 | node.setTitle("B", with: UIFont.systemFont(ofSize: 20.0), with: .black, for: .normal) 25 | node.setTitle("B", with: UIFont.systemFont(ofSize: 20.0), with: .lightGray, for: .disabled) 26 | node.setTitle("B", with: UIFont.systemFont(ofSize: 20.0, weight: .bold), with: .blue, for: .selected) 27 | return node 28 | }() 29 | 30 | lazy var italicNode: VEditorTypingControlNode = { 31 | let node = VEditorTypingControlNode(EditorRule.XML.italic.rawValue, 32 | rule: rule) 33 | node.style.preferredSize = Const.controlSize 34 | node.setTitle("I", with: UIFont.systemFont(ofSize: 20.0), with: .black, for: .normal) 35 | node.setTitle("I", with: UIFont.systemFont(ofSize: 20.0), with: .lightGray, for: .disabled) 36 | node.setTitle("I", with: UIFont.systemFont(ofSize: 20.0, weight: .bold), with: .blue, for: .selected) 37 | return node 38 | }() 39 | 40 | lazy var headingNode: VEditorTypingControlNode = { 41 | let node = VEditorTypingControlNode(EditorRule.XML.heading.rawValue, 42 | rule: rule, 43 | isBlockStyle: true) 44 | node.style.preferredSize = Const.controlSize 45 | node.setTitle("H", with: UIFont.systemFont(ofSize: 20.0), with: .black, for: .normal) 46 | node.setTitle("H", with: UIFont.systemFont(ofSize: 20.0), with: .lightGray, for: .disabled) 47 | node.setTitle("H", with: UIFont.systemFont(ofSize: 20.0, weight: .bold), with: .blue, for: .selected) 48 | return node 49 | }() 50 | 51 | lazy var quoteNode: VEditorTypingControlNode = { 52 | let node = VEditorTypingControlNode(EditorRule.XML.quote.rawValue, 53 | rule: rule, 54 | isBlockStyle: true) 55 | node.style.preferredSize = Const.controlSize 56 | node.setTitle("Q", with: UIFont.systemFont(ofSize: 20.0), with: .black, for: .normal) 57 | node.setTitle("Q", with: UIFont.systemFont(ofSize: 20.0), with: .lightGray, for: .disabled) 58 | node.setTitle("Q", with: UIFont.systemFont(ofSize: 20.0, weight: .bold), with: .blue, for: .selected) 59 | return node 60 | }() 61 | 62 | lazy var linkInsertNode: VEditorTypingControlNode = { 63 | let node = VEditorTypingControlNode(EditorRule.XML.article.rawValue, 64 | rule: rule, 65 | isExternalHandler: true) 66 | node.style.preferredSize = Const.controlSize 67 | node.setTitle("Link", with: UIFont.systemFont(ofSize: 20.0), with: .black, for: .normal) 68 | node.setTitle("Link", with: UIFont.systemFont(ofSize: 20.0), with: .lightGray, for: .disabled) 69 | node.setTitle("Link", with: UIFont.systemFont(ofSize: 20.0, weight: .bold), with: .blue, for: .selected) 70 | return node 71 | }() 72 | 73 | lazy var seperateLineNode: ASDisplayNode = { 74 | let node = ASDisplayNode() 75 | node.backgroundColor = .lightGray 76 | return node 77 | }() 78 | 79 | lazy var scrollNode: ASScrollNode = { 80 | let node = ASScrollNode() 81 | node.automaticallyManagesContentSize = true 82 | node.automaticallyManagesSubnodes = true 83 | node.scrollableDirections = [.left, .right] 84 | return node 85 | }() 86 | 87 | lazy var photoLoadControlNode: ASButtonNode = { 88 | let node = ASButtonNode() 89 | node.setImage(#imageLiteral(resourceName: "image.png"), for: .normal) 90 | node.style.preferredSize = Const.controlSize 91 | return node 92 | }() 93 | 94 | lazy var videoLoadControlNode: ASButtonNode = { 95 | let node = ASButtonNode() 96 | node.setImage(#imageLiteral(resourceName: "video.png"), for: .normal) 97 | node.style.preferredSize = Const.controlSize 98 | return node 99 | }() 100 | 101 | lazy var dismissNode: ASButtonNode = { 102 | let node = ASButtonNode() 103 | node.setImage(#imageLiteral(resourceName: "keyboard.png"), for: .normal) 104 | node.style.preferredSize = Const.controlSize 105 | return node 106 | }() 107 | 108 | var typingControlNodes: [VEditorTypingControlNode] { 109 | return [boldNode, italicNode, quoteNode, headingNode, linkInsertNode] 110 | } 111 | 112 | let rule: EditorRule 113 | 114 | init(rule: EditorRule) { 115 | self.rule = rule 116 | super.init() 117 | self.automaticallyManagesSubnodes = true 118 | self.backgroundColor = .white 119 | self.scrollNode.layoutSpecBlock = { [weak self] (_, _) -> ASLayoutSpec in 120 | return self?.controlButtonsGroupLayoutSpec() ?? ASLayoutSpec() 121 | } 122 | } 123 | 124 | override func didLoad() { 125 | super.didLoad() 126 | self.scrollNode.view.showsVerticalScrollIndicator = false 127 | self.scrollNode.view.showsHorizontalScrollIndicator = false 128 | } 129 | 130 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 131 | scrollNode.style.flexShrink = 1.0 132 | scrollNode.style.flexGrow = 0.0 133 | dismissNode.style.flexShrink = 1.0 134 | dismissNode.style.flexGrow = 0.0 135 | let stackLayout = ASStackLayoutSpec(direction: .horizontal, 136 | spacing: 5.0, 137 | justifyContent: .spaceBetween, 138 | alignItems: .stretch, 139 | children: [scrollNode, dismissNode]) 140 | let controlAreaLayout = ASInsetLayoutSpec(insets: Const.insets, child: stackLayout) 141 | seperateLineNode.style.height = .init(unit: .points, value: 0.5) 142 | return ASStackLayoutSpec(direction: .vertical, 143 | spacing: 0.0, 144 | justifyContent: .start, 145 | alignItems: .stretch, 146 | children: [seperateLineNode, 147 | controlAreaLayout]) 148 | } 149 | 150 | private func controlButtonsGroupLayoutSpec() -> ASLayoutSpec { 151 | let mediaControlNodes: [ASLayoutElement] = 152 | [photoLoadControlNode, videoLoadControlNode] 153 | return ASStackLayoutSpec(direction: .horizontal, 154 | spacing: 10.0, 155 | justifyContent: .start, 156 | alignItems: .start, 157 | children: mediaControlNodes + typingControlNodes) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Example/VEditorKit/Views/EditorOpenGraphPlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorPlaceholderCells.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | import RxCocoa 12 | import AsyncDisplayKit 13 | import VEditorKit 14 | 15 | public class EditorOpenGraphPlaceholder: VEditorMediaPlaceholderNode { 16 | 17 | lazy var containerNode: ASDisplayNode = { 18 | let node = ASDisplayNode() 19 | node.backgroundColor = .lightGray 20 | node.style.height = .init(unit: .points, value: 150) 21 | node.cornerRadius = 20.0 22 | return node 23 | }() 24 | 25 | lazy var indicatorNode: ASDisplayNode = 26 | ASDisplayNode.init(viewBlock: { () -> UIView in 27 | return UIActivityIndicatorView.init(style: .white) 28 | }) 29 | 30 | var indicatorView: UIActivityIndicatorView? { 31 | return indicatorNode.view as? UIActivityIndicatorView 32 | } 33 | 34 | init(xmlTag: String, url: URL) { 35 | super.init(xmlTag: xmlTag) 36 | 37 | MockService 38 | .getOgObject(url) 39 | .subscribe(onNext: { [weak self] attributes in 40 | guard let `self` = self else { 41 | fatalError() 42 | } 43 | let replaceContent = VOpenGraphContent(self.xmlTag, 44 | attributes: attributes) 45 | self.onSuccess(replaceContent) 46 | }, onError: { [weak self] _ in 47 | self?.onFailed() 48 | }).disposed(by: disposeBag) 49 | } 50 | 51 | override public func didEnterVisibleState() { 52 | super.didEnterVisibleState() 53 | self.indicatorView?.startAnimating() 54 | } 55 | 56 | override public func didExitVisibleState() { 57 | super.didExitVisibleState() 58 | self.indicatorView?.stopAnimating() 59 | } 60 | 61 | override public func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 62 | let centerLayout = ASCenterLayoutSpec(centeringOptions: .XY, 63 | sizingOptions: [], 64 | child: indicatorNode) 65 | let indicatorOverlayedLayout = ASOverlayLayoutSpec(child: containerNode, 66 | overlay: centerLayout) 67 | let insets: UIEdgeInsets = .init(top: 15.0, left: 5.0, bottom: 15.0, right: 5.0) 68 | return ASInsetLayoutSpec(insets: insets, child: indicatorOverlayedLayout) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Example/VEditorKit/content.xml: -------------------------------------------------------------------------------- 1 |

Welcome to VEditorKit

Lightweight and Powerful Editor Kit built on Texture(AsyncDisplayKit) https://github.com/texturegroup/texture. VEditorKit provides the most core functionality needed for the editor. Unfortunately, When combined words are entered then UITextView selectedRange will changed and typingAttribute will cleared. So, In combined words case, Users can't continue typing the style they want.

- Typing Attribute Test -

2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Geektree0101 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![CI Status](https://travis-ci.org/GeekTree0101/VEditorKit.svg?branch=master)](https://travis-ci.org/GeekTree0101/VEditorKit) 4 | [![Version](https://img.shields.io/cocoapods/v/VEditorKit.svg?style=flat)](https://cocoapods.org/pods/VEditorKit) 5 | [![License](https://img.shields.io/cocoapods/l/VEditorKit.svg?style=flat)](https://cocoapods.org/pods/VEditorKit) 6 | [![Platform](https://img.shields.io/cocoapods/p/VEditorKit.svg?style=flat)](https://cocoapods.org/pods/VEditorKit) 7 | 8 | Lightweight and Powerful Editor Kit built on Texture(AsyncDisplayKit) 9 | https://github.com/texturegroup/texture. 10 |
11 | VEditorKit provides the most core functionality needed for the editor. 12 | Unfortunately, When combined words are entered then UITextView selectedRange will changed and typingAttribute will cleared. So, In combined words case, Users can't continue typing the style they want. 13 |
14 | #### TypingAttributes Spec 15 | When the text view’s selection changes, the contents of the dictionary are cleared automatically. 16 | https://developer.apple.com/documentation/uikit/uitextview/1618629-typingattributes 17 | 18 | #### Basic spec list 19 | - Advanced EditableTextView (Support Combined words such as Korean) 20 | - Default Image, Video, Og-Object(Link Preview) UI Components 21 | - XML Parser & Builder 22 | - Editor Rule Base Development 23 | 24 | ## Example 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
Bi-direction attribute bindingCombined Words TypingAttributeRegex pattern base attributed typing
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
XML Parse & BuildDelete Media Content & Merge TextViews Auto-generate Link Preview
49 | 50 | ## Usage 51 | - > ## Wiki: https://github.com/GeekTree0101/VEditorKit/wiki 52 | 53 | ## Requirements 54 | - Xcode <~ 9.0 55 | - Swift 4.2 56 | - iOS <~ 9.3 57 | 58 | ## Installation 59 | 60 | VEditorKit is available through [CocoaPods](https://cocoapods.org). To install 61 | it, simply add the following line to your Podfile: 62 | 63 | ```ruby 64 | pod 'VEditorKit' 65 | ``` 66 | 67 | ## Author 68 | 69 | - #### Geektree0101 70 | - #### OhKanghoon 71 | - #### gkdlfm 72 | 73 | ## License 74 | 75 | VEditorKit is available under the MIT license. See the LICENSE file for more info. 76 | 77 | -------------------------------------------------------------------------------- /VEditorKit.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = 'VEditorKit' 4 | s.version = '1.4.5' 5 | s.summary = 'Lightweight and Powerful Editor Kit' 6 | 7 | s.description = 'Lightweight and Powerful Editor Kit built on Texture(AsyncDisplayKit)' 8 | s.homepage = 'https://github.com/Geektree0101/VEditorKit' 9 | s.license = { :type => 'MIT', :file => 'LICENSE' } 10 | s.author = { 'Geektree0101' => 'h2s1880@gmail.com' } 11 | s.source = { :git => 'https://github.com/Geektree0101/VEditorKit.git', :tag => s.version.to_s } 12 | 13 | s.ios.deployment_target = '9.0' 14 | s.requires_arc = true 15 | s.xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) AS_USE_VIDEO=1' } 16 | 17 | s.source_files = 'VEditorKit/**/*' 18 | s.dependency 'Texture', '~> 2.7' 19 | s.dependency 'BonMot' 20 | s.dependency 'RxSwift' 21 | s.dependency 'RxCocoa' 22 | end 23 | -------------------------------------------------------------------------------- /VEditorKit/Assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/VEditorKit/Assets/.gitkeep -------------------------------------------------------------------------------- /VEditorKit/Classes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/VEditorKit/Classes/.gitkeep -------------------------------------------------------------------------------- /VEditorKit/Classes/Platform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Platform.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import BonMot 10 | import RxSwift 11 | import AsyncDisplayKit 12 | 13 | public typealias VEditorStyleAttribute = BonMot.StyleAttributes 14 | public typealias VEditorStyle = BonMot.StringStyle 15 | 16 | // MARK: - VEditor Parser Scope 17 | public enum VEditorParserResultScope { 18 | 19 | case error(Error?) 20 | case success([VEditorContent]) 21 | } 22 | 23 | // MARK: - VEditor Unit Content 24 | extension NSAttributedString: VEditorContent { } 25 | 26 | public let VEditorAttributeKey: NSAttributedString.Key = .init(rawValue: "VEditorKit.AttributeKey") 27 | 28 | public protocol VEditorMediaContent: VEditorContent { 29 | 30 | var xmlTag: String { get } 31 | init(_ xmlTag: String, attributes: [String: String]) 32 | func parseAttributeToXML() -> [String: String] // parseto xml attribute from media content 33 | } 34 | 35 | public struct VEditorPlaceholderContent: VEditorContent { 36 | public var xmlTag: String 37 | public var model: Any 38 | 39 | public init(xmlTag: String, model: Any) { 40 | self.xmlTag = xmlTag 41 | self.model = model 42 | } 43 | } 44 | 45 | // MARK: - VEditorKit Editor Rule 46 | public protocol VEditorRule { 47 | 48 | var allXML: [String] { get } 49 | var defaultStyleXMLTag: String { get } 50 | var blockStyleXMLTags: [String] { get } 51 | var linkStyleXMLTag: String? { get } 52 | func paragraphStyle(_ xmlTag: String, attributes: [String: String]) -> VEditorStyle? 53 | func build(_ xmlTag: String, attributes: [String: String]) -> VEditorMediaContent? 54 | func parseAttributeToXML(_ xmlTag: String, attributes: [NSAttributedString.Key: Any]) -> [String: String]? 55 | 56 | func enableTypingXMLs(_ inActiveXML: String) -> [String]? 57 | func disableTypingXMLs(_ activeXML: String) -> [String]? 58 | func inactiveTypingXMLs(_ activeXML: String) -> [String]? 59 | func activeTypingXMLs(_ inactiveXML: String) -> [String]? 60 | } 61 | 62 | extension VEditorRule { 63 | 64 | public func defaultAttribute() -> [NSAttributedString.Key: Any] { 65 | guard let attr = self.paragraphStyle(self.defaultStyleXMLTag, attributes: [:]) else { 66 | fatalError("Please setup default:\(self.defaultStyleXMLTag) xml tag style") 67 | } 68 | return attr.byAdding([.extraAttributes([VEditorAttributeKey: [self.defaultStyleXMLTag]])]).attributes 69 | } 70 | 71 | public func linkAttribute(_ url: URL) -> [NSAttributedString.Key: Any]? { 72 | guard let xml = self.linkStyleXMLTag, 73 | let attr = self.paragraphStyle(xml, attributes: [:]) else { return nil } 74 | return attr.byAdding([.link(url), .extraAttributes([VEditorAttributeKey: [xml]])]).attributes 75 | } 76 | } 77 | 78 | // MARK: VEditotKit EditorNodeDelegate 79 | public protocol VEditorNodeDelegate: class { 80 | 81 | func getRegisterTypingControls() -> [VEditorTypingControlNode]? 82 | func dismissKeyboardNode() -> ASControlNode? 83 | func placeholderCellNode(_ content: VEditorPlaceholderContent, 84 | indexPath: IndexPath) -> VEditorMediaPlaceholderNode? 85 | func contentCellNode(_ content: VEditorContent, 86 | indexPath: IndexPath) -> ASCellNode? 87 | } 88 | 89 | // MARK: - VEditor Regex Text Atttribute Apply Delegate 90 | public protocol VEditorRegexApplierDelegate: class { 91 | 92 | var allPattern: [String] { get } 93 | func paragraphStyle(pattern: String) -> VEditorStyle? 94 | func handlePatternTouchEvent(_ pattern: String, value: Any) 95 | func handlURLTouchEvent(_ url: URL) 96 | } 97 | 98 | extension VEditorRegexApplierDelegate { 99 | 100 | func regex(_ pattern: String) -> NSRegularExpression { 101 | guard let reg = try? NSRegularExpression.init(pattern: pattern, options: []) else { 102 | fatalError("VEditorKit Fatal Error: \(pattern) is invalid regex pattern") 103 | } 104 | return reg 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorDeleteMediaNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorDeleteMediaNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | open class VEditorDeleteMediaNode: ASControlNode { 14 | 15 | open let deleteButtonNode = ASButtonNode() 16 | 17 | public init(_ color: UIColor = .red, 18 | borderWidth: CGFloat = 5.0, // default is 5.0pt 19 | iconImage: UIImage? = nil, 20 | buttonSize: CGSize = .init(width: 50.0, height: 50.0)) { 21 | super.init() 22 | self.deleteButtonNode.style.preferredSize = buttonSize 23 | self.deleteButtonNode.backgroundColor = color 24 | self.deleteButtonNode.setImage(iconImage, for: .normal) 25 | self.borderWidth = borderWidth 26 | self.borderColor = color.cgColor 27 | self.automaticallyManagesSubnodes = true 28 | self.isHidden = true 29 | } 30 | 31 | override open func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 32 | return ASRelativeLayoutSpec(horizontalPosition: .end, 33 | verticalPosition: .start, 34 | sizingOption: [], 35 | child: deleteButtonNode) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorImageNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorImageNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import RxCocoa 12 | import RxSwift 13 | 14 | open class VEditorImageNode: VEditorMediaNode { 15 | 16 | public required init(isEdit: Bool, 17 | deleteNode: VEditorDeleteMediaNode = .init()) { 18 | super.init(node: .init(), 19 | deleteNode: deleteNode, 20 | isEdit: isEdit) 21 | self.node.backgroundColor = .lightGray 22 | self.node.placeholderColor = .lightGray 23 | self.automaticallyManagesSubnodes = true 24 | self.selectionStyle = .none 25 | } 26 | 27 | /** 28 | Set image url 29 | 30 | - important: If you set filePath url than will load data from filepath with make image 31 | 32 | - parameters: 33 | - url: image network url or filepath local url 34 | - returns: self (VEditorImageNode) 35 | */ 36 | @discardableResult open func setURL(_ url: URL?) -> Self { 37 | if url?.isFileURL ?? false { 38 | guard let imageFileURL = url, 39 | let imageData = try? Data(contentsOf: imageFileURL, 40 | options: []) else { return self } 41 | self.node.image = UIImage(data: imageData) 42 | } else { 43 | self.node.setURL(url, resetToDefault: true) 44 | } 45 | return self 46 | } 47 | 48 | /** 49 | Set imageNode placeholder color 50 | 51 | - parameters: 52 | - color: placeholder color, default is lightGray 53 | - returns: self (VEditorImageNode) 54 | */ 55 | @discardableResult open func setPlaceholderColor(_ color: UIColor) -> Self { 56 | self.node.placeholderColor = color 57 | return self 58 | } 59 | 60 | /** 61 | Set imageNode background color 62 | 63 | - parameters: 64 | - color: background color, default is lightGray 65 | - returns: self (VEditorImageNode) 66 | */ 67 | @discardableResult open func setBackgroundColor(_ color: UIColor) -> Self { 68 | self.node.backgroundColor = color 69 | return self 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorMediaNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorMedia.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import RxSwift 12 | import RxCocoa 13 | 14 | // ***** TIP ***** 15 | // If you make VEditorMediaNode subclass 16 | // you will got advantage about delete & textInsertion automatically binding on VEditorNode 17 | public protocol VEditorMediaNodeEventProtocol { 18 | 19 | var didTapDeleteObservable: Observable { get } 20 | var didTapTextInsertObservable: Observable { get } 21 | var isEdit: Bool { get } 22 | var disposeBag: DisposeBag { get } 23 | } 24 | 25 | open class VEditorMediaNode: ASCellNode, VEditorMediaNodeEventProtocol { 26 | 27 | open let node: TargetNode 28 | 29 | open lazy var textInsertionNode: ASControlNode = { 30 | let node = ASControlNode() 31 | node.backgroundColor = .clear 32 | node.style.height = .init(unit: .points, value: 5.0) 33 | return node 34 | }() 35 | 36 | open let deleteControlNode: VEditorDeleteMediaNode 37 | 38 | public let textInsertionRelay = PublishRelay() 39 | public let didTapDeleteRelay = PublishRelay() 40 | open var insets: UIEdgeInsets = .zero 41 | open var ratio: CGFloat = 1.0 42 | open var isEdit: Bool = true 43 | 44 | open var didTapDeleteObservable: Observable { 45 | return self.didTapDeleteRelay.asObservable() 46 | } 47 | 48 | open var didTapTextInsertObservable: Observable { 49 | return self.textInsertionRelay.asObservable() 50 | } 51 | 52 | public var disposeBag: DisposeBag = DisposeBag() 53 | 54 | public init(node: TargetNode, 55 | deleteNode: VEditorDeleteMediaNode, 56 | isEdit: Bool) { 57 | self.node = node as! TargetNode 58 | self.isEdit = isEdit 59 | self.deleteControlNode = deleteNode 60 | super.init() 61 | self.automaticallyManagesSubnodes = true 62 | self.selectionStyle = .none 63 | } 64 | 65 | /** 66 | Set insets 67 | 68 | - parameters: 69 | - insets: update insets 70 | - returns: self (VEditorMediaNode or subclass) 71 | */ 72 | @discardableResult open func setContentInsets(_ insets: UIEdgeInsets) -> Self { 73 | self.insets = insets 74 | return self 75 | } 76 | 77 | /** 78 | Set media node ratio 79 | 80 | - parameters: 81 | - ratio: update media node ratio 82 | - returns: self (VEditorMediaNode or subclass) 83 | */ 84 | @discardableResult open func setMediaRatio(_ ratio: CGFloat) -> Self { 85 | self.ratio = ratio 86 | return self 87 | } 88 | 89 | /** 90 | Set text insertion area height 91 | 92 | - parameters: 93 | - height: touch area height 94 | - returns: self (VEditorMediaNode or subclass) 95 | */ 96 | @discardableResult open func setTextInsertionHeight(_ height: CGFloat) -> Self { 97 | self.textInsertionNode.style.height = .init(unit: .points, value: height) 98 | return self 99 | } 100 | 101 | override open func didLoad() { 102 | super.didLoad() 103 | guard self.isEdit else { return } 104 | node.addTarget(self, 105 | action: #selector(didTapMedia), 106 | forControlEvents: .touchUpInside) 107 | deleteControlNode.addTarget(self, 108 | action: #selector(didTapMedia), 109 | forControlEvents: .touchUpInside) 110 | textInsertionNode.addTarget(self, 111 | action: #selector(didTapTextInsertion), 112 | forControlEvents: .touchUpInside) 113 | deleteControlNode.deleteButtonNode 114 | .addTarget(self, 115 | action: #selector(didTapDelete), 116 | forControlEvents: .touchUpInside) 117 | } 118 | 119 | /** 120 | Did tap media content event 121 | 122 | - important: It can control delete control box hidden status with anything else 123 | If you needs more customize logic than have to override this method! 124 | - returns: (Void) 125 | */ 126 | @objc open func didTapMedia() { 127 | guard self.isEdit else { return } 128 | self.deleteControlNode.isHidden = !self.deleteControlNode.isHidden 129 | self.setNeedsLayout() 130 | } 131 | 132 | /** 133 | Did tap text insertion event 134 | 135 | - important: It emit textInsert event (insert textView between medias) 136 | If you needs more customize logic than have to override this method! 137 | - returns: (Void) 138 | */ 139 | @objc open func didTapTextInsertion() { 140 | guard let indexPath = self.indexPath else { return } 141 | self.textInsertionRelay.accept(indexPath) 142 | } 143 | 144 | /** 145 | Did tap delete media 146 | 147 | - important: It emit delete touched media content from editor 148 | If you needs more customize logic than have to override this method! 149 | - returns: (Void) 150 | */ 151 | @objc open func didTapDelete() { 152 | guard let indexPath = self.indexPath else { return } 153 | self.didTapDeleteRelay.accept(indexPath) 154 | } 155 | 156 | /** 157 | Make media content layoutSpec 158 | 159 | - important: If you needs attach subnodes or layout on media content 160 | than you have to override this method 161 | 162 | - parameters: constrainedSize(ASSizeRange) 163 | - returns: Media content layout spec (default is media ratioLayout) 164 | */ 165 | open func mediaContentLayoutSpec(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 166 | return ASRatioLayoutSpec(ratio: ratio, child: node) 167 | } 168 | 169 | override open func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 170 | let mediaRatioLayout = self.mediaContentLayoutSpec(constrainedSize) 171 | 172 | let mediaContentLayout: ASLayoutElement 173 | 174 | if isEdit { 175 | let deleteableMediaLayout = ASOverlayLayoutSpec(child: mediaRatioLayout, 176 | overlay: deleteControlNode) 177 | mediaContentLayout = 178 | ASStackLayoutSpec(direction: .vertical, 179 | spacing: 0.0, 180 | justifyContent: .start, 181 | alignItems: .stretch, 182 | children: [textInsertionNode, 183 | deleteableMediaLayout]) 184 | } else { 185 | mediaContentLayout = mediaRatioLayout 186 | } 187 | 188 | return ASInsetLayoutSpec(insets: insets, 189 | child: mediaContentLayout) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorMediaPlaceholderNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorMediaPlaceholderNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | import RxCocoa 12 | import AsyncDisplayKit 13 | 14 | extension Reactive where Base: VEditorMediaPlaceholderNode { 15 | 16 | public var success: Observable<(VEditorContent, IndexPath)> { 17 | return base.successRelay.asObservable().take(1) 18 | } 19 | 20 | public var failed: Observable { 21 | return base.failedRelay.asObservable().take(1) 22 | } 23 | } 24 | 25 | open class VEditorMediaPlaceholderNode: ASCellNode { 26 | 27 | internal let successRelay = PublishRelay<(VEditorContent, IndexPath)>() 28 | internal let failedRelay = PublishRelay() 29 | internal var lazyHandlerWorkItem: DispatchWorkItem? 30 | 31 | public let xmlTag: String 32 | public let disposeBag = DisposeBag() 33 | 34 | public init(xmlTag: String) { 35 | self.xmlTag = xmlTag 36 | super.init() 37 | self.automaticallyManagesSubnodes = true 38 | self.selectionStyle = .none 39 | } 40 | 41 | override open func didLoad() { 42 | super.didLoad() 43 | guard let workItem = self.lazyHandlerWorkItem else { return } 44 | DispatchQueue.main.async(execute: workItem) 45 | } 46 | 47 | public func onSuccess(_ replaceContent: VEditorContent) { 48 | guard let indexPath = self.indexPath else { 49 | lazyHandlerWorkItem = DispatchWorkItem(block: { 50 | self.onSuccess(replaceContent) 51 | }) 52 | return 53 | } 54 | self.successRelay.accept((replaceContent, indexPath)) 55 | } 56 | 57 | public func onFailed() { 58 | guard let indexPath = self.indexPath else { 59 | lazyHandlerWorkItem = DispatchWorkItem(block: { 60 | self.onFailed() 61 | }) 62 | return 63 | } 64 | self.failedRelay.accept(indexPath) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorOpenGraphNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorOpenGraphNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import BonMot 12 | import RxSwift 13 | import RxCocoa 14 | 15 | extension Reactive where Base: VEditorOpenGraphNode { 16 | 17 | public var didTapDelete: Observable { 18 | return base.didTapDeleteRelay.asObservable() 19 | } 20 | } 21 | 22 | open class VEditorOpenGraphNode: ASCellNode { 23 | 24 | lazy var imageNode: ASNetworkImageNode = { 25 | let node = ASNetworkImageNode() 26 | node.cornerRadius = 5.0 27 | node.backgroundColor = .lightGray 28 | return node 29 | }() 30 | 31 | lazy var titleNode: ASTextNode = { 32 | let node = ASTextNode() 33 | node.maximumNumberOfLines = 1 34 | return node 35 | }() 36 | 37 | lazy var descNode: ASTextNode = { 38 | let node = ASTextNode() 39 | node.maximumNumberOfLines = 3 40 | return node 41 | }() 42 | 43 | lazy var sourceNode: ASTextNode = { 44 | let node = ASTextNode() 45 | node.maximumNumberOfLines = 1 46 | return node 47 | }() 48 | 49 | lazy var containerNode: ASControlNode = { 50 | let node = ASControlNode() 51 | node.automaticallyManagesSubnodes = true 52 | node.borderWidth = 1.0 53 | node.borderColor = UIColor.lightGray.cgColor 54 | node.cornerRadius = 10.0 55 | return node 56 | }() 57 | 58 | public let deleteControlNode: VEditorDeleteMediaNode 59 | 60 | public var insets: UIEdgeInsets = .zero 61 | public var containerInsets: UIEdgeInsets = .zero 62 | public var isEdit: Bool = true 63 | public var imageRatio: CGFloat? 64 | public var contentSpacing: CGFloat = 5.0 65 | public var imageWithContentSpacing: CGFloat = 5.0 66 | public var disposeBag = DisposeBag() 67 | public let didTapDeleteRelay = PublishRelay() 68 | 69 | public required init(isEdit: Bool, 70 | deleteNode: VEditorDeleteMediaNode = .init()) { 71 | self.isEdit = isEdit 72 | self.deleteControlNode = deleteNode 73 | super.init() 74 | self.automaticallyManagesSubnodes = true 75 | self.selectionStyle = .none 76 | self.containerNode.layoutSpecBlock = { [weak self] (_, _) -> ASLayoutSpec in 77 | return self?.containerLayoutSpec() ?? ASLayoutSpec() 78 | } 79 | } 80 | 81 | override open func didLoad() { 82 | super.didLoad() 83 | guard self.isEdit else { return } 84 | self.deleteControlNode.isHidden = true 85 | containerNode.addTarget(self, action: #selector(didTapOpengraph), forControlEvents: .touchUpInside) 86 | deleteControlNode.addTarget(self, action: #selector(didTapOpengraph), forControlEvents: .touchUpInside) 87 | deleteControlNode.deleteButtonNode 88 | .addTarget(self, 89 | action: #selector(didTapDelete), 90 | forControlEvents: .touchUpInside) 91 | } 92 | 93 | @objc func didTapDelete() { 94 | guard let indexPath = self.indexPath else { return } 95 | self.didTapDeleteRelay.accept(indexPath) 96 | } 97 | 98 | @objc public func didTapOpengraph() { 99 | guard self.isEdit else { return } 100 | self.deleteControlNode.isHidden = !self.deleteControlNode.isHidden 101 | self.containerNode.setNeedsLayout() 102 | } 103 | 104 | @discardableResult open func setContentInsets(_ insets: UIEdgeInsets) -> Self { 105 | self.insets = insets 106 | return self 107 | } 108 | 109 | @discardableResult open func setContainerInsets(_ insets: UIEdgeInsets) -> Self { 110 | self.containerInsets = insets 111 | return self 112 | } 113 | 114 | @discardableResult open func setPreviewImageRatio(_ ratio: CGFloat) -> Self { 115 | self.imageRatio = ratio 116 | return self 117 | } 118 | 119 | @discardableResult open func setPreviewImageSize(_ size: CGSize, 120 | cornerRadius: CGFloat) -> Self { 121 | self.imageNode.style.preferredSize = size 122 | self.imageNode.cornerRadius = cornerRadius 123 | self.imageRatio = nil 124 | return self 125 | } 126 | 127 | @discardableResult open func setPreviewImageURL(_ url: URL?) -> Self { 128 | self.imageNode.setURL(url, resetToDefault: true) 129 | return self 130 | } 131 | 132 | @discardableResult open func setPlaceholderColor(_ color: UIColor) -> Self { 133 | self.imageNode.placeholderColor = color 134 | return self 135 | } 136 | 137 | @discardableResult open func setTitleAttribute(_ text: String?, 138 | attrStyle: VEditorStyle) -> Self { 139 | self.titleNode.attributedText = text?.styled(with: attrStyle) 140 | return self 141 | } 142 | 143 | @discardableResult open func setDescAttribute(_ text: String?, 144 | attrStyle: VEditorStyle) -> Self { 145 | self.descNode.attributedText = text?.styled(with: attrStyle) 146 | return self 147 | } 148 | 149 | @discardableResult open func setSourceAttribute(_ url: URL?, 150 | attrStyle: VEditorStyle) -> Self { 151 | guard let url = url else { return self } 152 | self.sourceNode.attributedText = 153 | url.absoluteString.styled(with: attrStyle.byAdding([.link(url)])) 154 | return self 155 | } 156 | 157 | override open func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 158 | if isEdit { 159 | let overlayLayout = ASOverlayLayoutSpec(child: containerNode, 160 | overlay: deleteControlNode) 161 | return ASInsetLayoutSpec(insets: insets, child: overlayLayout) 162 | } else { 163 | return ASInsetLayoutSpec(insets: insets, child: containerNode) 164 | } 165 | } 166 | 167 | open func containerLayoutSpec() -> ASLayoutSpec { 168 | var elements: [ASLayoutElement] = [] 169 | 170 | if titleNode.attributedText?.length ?? 0 > 0 { 171 | elements.append(titleNode) 172 | } 173 | 174 | if descNode.attributedText?.length ?? 0 > 0 { 175 | elements.append(descNode) 176 | } 177 | 178 | if sourceNode.attributedText?.length ?? 0 > 0 { 179 | elements.append(sourceNode) 180 | } 181 | 182 | let contentAreaLayout = ASStackLayoutSpec(direction: .vertical, 183 | spacing: contentSpacing, 184 | justifyContent: .start, 185 | alignItems: .stretch, 186 | children: elements) 187 | contentAreaLayout.style.flexShrink = 1.0 188 | contentAreaLayout.style.flexGrow = 0.0 189 | imageNode.style.flexShrink = 0.0 190 | imageNode.style.flexGrow = 0.0 191 | 192 | let openGraphLayout = ASStackLayoutSpec(direction: .horizontal, 193 | spacing: imageWithContentSpacing, 194 | justifyContent: .spaceBetween, 195 | alignItems: .center, 196 | children: [contentAreaLayout, 197 | imageNode]) 198 | 199 | return ASInsetLayoutSpec(insets: containerInsets, 200 | child: openGraphLayout) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorParser.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import BonMot 11 | import RxSwift 12 | import RxCocoa 13 | 14 | public protocol VEditorContent { } 15 | extension String: VEditorContent { } 16 | 17 | public extension Reactive where Base: VEditorParser { 18 | 19 | public var result: Observable { 20 | return base.resultRelay.asObservable() 21 | } 22 | } 23 | 24 | public final class VEditorParser: NSObject, XMLStyler { 25 | 26 | private let parserRule: VEditorRule 27 | private let styleRules: [XMLStyleRule] 28 | public let resultRelay = PublishRelay() 29 | 30 | public init(rule: VEditorRule) { 31 | self.parserRule = rule 32 | self.styleRules = rule.allXML.map({ xmlTag -> XMLStyleRule in 33 | return XMLStyleRule.style(xmlTag, StringStyle.init()) 34 | }) 35 | super.init() 36 | } 37 | 38 | public func parseXML(_ xmlString: String, 39 | onSuccess: (([VEditorContent]) -> Void)? = nil, 40 | onError: ((Error?) -> Void)? = nil) { 41 | var xmlString = xmlString.convertDuplicatedBackSlashToValidParserXML() 42 | 43 | // make newlink before block heading if needs 44 | for blockXML in parserRule.blockStyleXMLTags { 45 | let defaultCloseTag: String = "" 46 | let blockOpenTag: String = "<\(blockXML)>" 47 | let targetPairTag: String = defaultCloseTag + blockOpenTag 48 | xmlString = xmlString.replacingOccurrences(of: targetPairTag, 49 | with: "\n" + targetPairTag) 50 | } 51 | 52 | let parser = VEditorContentParser(xmlString, rule: self.parserRule) 53 | 54 | switch parser.parseXMLContents() { 55 | case .success(let contents): 56 | let styleAppliedContents = contents 57 | .map({ content -> VEditorContent in 58 | if let xmlContent = content as? String { 59 | return xmlContent.styled(with: StringStyle(.xmlStyler(self))) 60 | } else { 61 | return content 62 | } 63 | }) 64 | onSuccess?(styleAppliedContents) 65 | resultRelay.accept(.success(styleAppliedContents)) 66 | case .error(let error): 67 | onError?(error) 68 | resultRelay.accept(.error(error)) 69 | } 70 | } 71 | 72 | public func parseXMLToAttributedString(_ xmlString: String) -> NSAttributedString { 73 | return xmlString.styled(with: StringStyle(.xmlStyler(self))) 74 | } 75 | 76 | public func style(forElement name: String, 77 | attributes: [String : String], 78 | currentStyle: StringStyle) -> StringStyle? { 79 | for rule in styleRules { 80 | switch rule { 81 | case let .style(string, style) where string == name: 82 | var mutableStyle: StringStyle 83 | if let paragraphStyle = parserRule.paragraphStyle(name, attributes: attributes) { 84 | mutableStyle = paragraphStyle 85 | } else { 86 | mutableStyle = style 87 | } 88 | 89 | // *** Merge topStyle cached xmlTags list with current xmlTag *** 90 | if let cachedXmlTags = currentStyle.attributes[VEditorAttributeKey] as? [String], 91 | !cachedXmlTags.contains(name) { 92 | mutableStyle.add(extraAttributes: [VEditorAttributeKey: [name] + cachedXmlTags]) 93 | } else { 94 | mutableStyle.add(extraAttributes: [VEditorAttributeKey: [name]]) 95 | } 96 | 97 | // *** In many kinds of xmlTags contained case, remove default XMLTag 98 | if let cachedXmlTags = mutableStyle.attributes[VEditorAttributeKey] as? [String], 99 | cachedXmlTags.count > 1, 100 | cachedXmlTags.contains(parserRule.defaultStyleXMLTag) { 101 | let defaultXMLFilteredXMLTags = cachedXmlTags.filter({ $0 != parserRule.defaultStyleXMLTag }) 102 | mutableStyle.add(extraAttributes: [VEditorAttributeKey: defaultXMLFilteredXMLTags]) 103 | } 104 | 105 | return mutableStyle 106 | 107 | default: 108 | break 109 | } 110 | } 111 | for rule in styleRules { 112 | if case let .styles(namedStyles) = rule { 113 | return namedStyles.style(forName: name) 114 | } 115 | } 116 | return nil 117 | } 118 | 119 | public func prefix(forElement name: String, 120 | attributes: [String: String]) -> Composable? { 121 | for rule in styleRules { 122 | switch rule { 123 | case let .enter(string, composable) where string == name: 124 | return composable 125 | default: break 126 | } 127 | } 128 | return nil 129 | } 130 | 131 | public func suffix(forElement name: String) -> Composable? { 132 | for rule in styleRules { 133 | switch rule { 134 | case let .exit(string, composable) where string == name: 135 | return composable 136 | default: break 137 | } 138 | } 139 | return nil 140 | } 141 | } 142 | 143 | /** 144 | Editor Content Parser 145 | 146 | General pattern would be: 147 | xml:

hello world

done

148 | will convert to 149 | - [0]:

hello world

150 | - [1]: VEditorMediaContent 151 | - [2]:

done

152 | */ 153 | internal class VEditorContentParser: NSObject, XMLParserDelegate { 154 | 155 | private let parser: XMLParser 156 | private let parserRule: VEditorRule 157 | private var contents: [VEditorContent] = [] 158 | 159 | init(_ xmlString: String, 160 | rule: VEditorRule) { 161 | guard let data = xmlString.data(using: .utf8) else { 162 | fatalError("Failed to convert data from string as utf8") 163 | } 164 | self.parser = XMLParser.init(data: data) 165 | self.parserRule = rule 166 | super.init() 167 | self.parser.delegate = self 168 | self.parser.shouldProcessNamespaces = false 169 | self.parser.shouldReportNamespacePrefixes = false 170 | self.parser.shouldResolveExternalEntities = false 171 | } 172 | 173 | func parseXMLContents() -> VEditorParserResultScope { 174 | guard parser.parse() else { 175 | return .error(parser.parserError) 176 | } 177 | return .success(contents) 178 | } 179 | 180 | func parser(_ parser: XMLParser, 181 | didStartElement elementName: String, 182 | namespaceURI: String?, 183 | qualifiedName qName: String?, 184 | attributes attributeDict: [String : String] = [:]) { 185 | if parserRule.paragraphStyle(elementName, attributes: attributeDict) != nil { 186 | self.contents.append("<\(elementName) \(attributeDict.xmlAttributeToString())>") 187 | } else if let build = parserRule.build(elementName, attributes: attributeDict) { 188 | self.contents.append(build) 189 | } 190 | } 191 | 192 | func parser(_ parser: XMLParser, foundCharacters string: String) { 193 | if self.contents.last is String { 194 | self.contents.append(string.encodingHTMLEntities()) 195 | } 196 | } 197 | 198 | func parser(_ parser: XMLParser, 199 | didEndElement elementName: String, 200 | namespaceURI: String?, 201 | qualifiedName qName: String?) { 202 | if parserRule.paragraphStyle(elementName, attributes: [:]) != nil { 203 | self.contents.append("") 204 | } 205 | } 206 | 207 | func parserDidEndDocument(_ parser: XMLParser) { 208 | self.contents = contents.reduce([], { result, item -> [VEditorContent] in 209 | var result: [VEditorContent] = result 210 | if result.last is String, 211 | let strItem = item as? String { 212 | result.append(((result.removeLast() as? String) ?? "") + strItem) 213 | } else { 214 | result.append(item) 215 | } 216 | return result 217 | }) 218 | } 219 | } 220 | 221 | extension Dictionary where Key == String, Value == String { 222 | 223 | internal func xmlAttributeToString() -> String { 224 | var attributes: [String] = [] 225 | self.enumerated().forEach({ _, context in 226 | let (key, value) = context 227 | attributes.append("\(key)=\"\(value)\"") 228 | }) 229 | return attributes.joined(separator: " ") 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorTextCellNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTextCellNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import RxSwift 12 | import RxCocoa 13 | 14 | extension Reactive where Base: VEditorTextCellNode { 15 | 16 | public var becomeActive: Observable { 17 | return base.textNode.rx.becomeActive.asObservable() 18 | } 19 | 20 | public var deleted: Observable { 21 | return base.deleteRelay.filter({ $0 != nil }).map({ $0! }) 22 | } 23 | } 24 | 25 | open class VEditorTextCellNode: ASCellNode { 26 | 27 | public var insets: UIEdgeInsets = .zero 28 | public var isEdit: Bool = true 29 | public var textNode: VEditorTextNode 30 | public let disposeBag = DisposeBag() 31 | public let deleteRelay = PublishRelay() 32 | 33 | public required init(isEdit: Bool, 34 | placeholderText: NSAttributedString?, 35 | attributedText: NSAttributedString, 36 | rule: VEditorRule, 37 | regexDelegate: VEditorRegexApplierDelegate? = nil, 38 | automaticallyGenerateLinkPreview: Bool = false) { 39 | self.isEdit = isEdit 40 | self.textNode = VEditorTextNode(rule, 41 | isEdit: isEdit, 42 | placeholderText: placeholderText, 43 | attributedText: attributedText) 44 | self.textNode.regexDelegate = regexDelegate 45 | self.textNode.automaticallyGenerateLinkPreview = automaticallyGenerateLinkPreview 46 | super.init() 47 | self.automaticallyManagesSubnodes = true 48 | self.selectionStyle = .none 49 | 50 | self.textNode.rx.textEmptied 51 | .map { [weak self] _ -> IndexPath? in 52 | return self?.indexPath 53 | } 54 | .bind(to: deleteRelay) 55 | .disposed(by: disposeBag) 56 | } 57 | 58 | @discardableResult open func setContentInsets(_ insets: UIEdgeInsets) -> Self { 59 | self.insets = insets 60 | return self 61 | } 62 | 63 | override open func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 64 | return ASInsetLayoutSpec(insets: insets, child: self.textNode) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorTextNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTextNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import RxCocoa 12 | import RxSwift 13 | 14 | extension Reactive where Base: VEditorTextNode { 15 | 16 | public var currentLocationXMLTags: Observable<[String]> { 17 | return base.currentLocationXMLTagsRelay 18 | .throttle(0.1, scheduler: MainScheduler.instance) 19 | } 20 | 21 | public var becomeActive: Observable { 22 | return base.becomeActiveRelay.asObservable() 23 | } 24 | 25 | internal var caretRect: Observable { 26 | return base.caretRectRelay 27 | .distinctUntilChanged() 28 | .throttle(0.1, scheduler: MainScheduler.instance) 29 | } 30 | 31 | internal var generateLinkPreview: Observable<(URL, Int)> { 32 | return base.generateLinkPreviewRelay.asObservable() 33 | } 34 | 35 | internal var textEmptied: Observable { 36 | return base.textEmptiedRelay.asObservable() 37 | } 38 | } 39 | 40 | open class VEditorTextNode: ASEditableTextNode, ASEditableTextNodeDelegate { 41 | 42 | open var textStorage: VEditorTextStorage? { 43 | return self.textView.textStorage as? VEditorTextStorage 44 | } 45 | 46 | open var currentTypingAttribute: [NSAttributedString.Key: Any] = [:] { 47 | didSet { 48 | self.typingAttributes = currentTypingAttribute.typingAttribute() 49 | self.textStorage?.currentTypingAttribute = currentTypingAttribute 50 | } 51 | } 52 | 53 | open var isEdit: Bool = true 54 | open weak var regexDelegate: VEditorRegexApplierDelegate! 55 | open var automaticallyGenerateLinkPreview: Bool = false 56 | open let becomeActiveRelay = PublishRelay() 57 | 58 | internal let rule: VEditorRule 59 | internal let currentLocationXMLTagsRelay = PublishRelay<[String]>() 60 | internal let caretRectRelay = PublishRelay() 61 | internal let generateLinkPreviewRelay = PublishRelay<(URL, Int)>() 62 | internal let textEmptiedRelay = PublishRelay() 63 | 64 | public required init(_ rule: VEditorRule, 65 | isEdit: Bool, 66 | placeholderText: NSAttributedString?, 67 | attributedText: NSAttributedString) { 68 | self.isEdit = isEdit 69 | self.rule = rule 70 | 71 | let textStorage = VEditorTextStorage.init() 72 | let textKitComponents: ASTextKitComponents = 73 | .init(textStorage: textStorage, 74 | textContainerSize: .zero, 75 | layoutManager: .init()) 76 | 77 | let placeholderTextKit: ASTextKitComponents = 78 | .init(attributedSeedString: placeholderText, 79 | textContainerSize: .zero) 80 | 81 | super.init(textKitComponents: textKitComponents, 82 | placeholderTextKitComponents: placeholderTextKit) 83 | super.delegate = self 84 | self.style.minHeight = .init(unit: .points, value: self.minimumTextContainerTextLineHeight()) 85 | self.scrollEnabled = false 86 | self.attributedText = attributedText 87 | } 88 | 89 | override open func didLoad() { 90 | super.didLoad() 91 | self.currentTypingAttribute = rule.defaultAttribute() 92 | if let linkXML = rule.linkStyleXMLTag, 93 | let attrStyle = rule.paragraphStyle(linkXML, attributes: [:]) { 94 | self.textView.linkTextAttributes = attrStyle.attributes 95 | } 96 | 97 | self.textStorage?.replaceAttributeWithRegexPattenIfNeeds(self) 98 | } 99 | 100 | open func minimumTextContainerTextLineHeight() -> CGFloat { 101 | guard let paragraph = rule.defaultAttribute()[.paragraphStyle] as? NSParagraphStyle else { 102 | return 0.0 103 | } 104 | return paragraph.minimumLineHeight 105 | } 106 | 107 | open func editableTextNodeShouldBeginEditing(_ editableTextNode: ASEditableTextNode) -> Bool { 108 | return self.isEdit 109 | } 110 | 111 | open func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) { 112 | self.becomeActiveRelay.accept(()) 113 | } 114 | 115 | open func editableTextNode(_ editableTextNode: ASEditableTextNode, 116 | shouldChangeTextIn range: NSRange, 117 | replacementText text: String) -> Bool { 118 | if text.isEmpty { 119 | guard self.isDisplayingPlaceholder() else { return true } 120 | self.textEmptiedRelay.accept(()) 121 | return true 122 | } else if (text == "\n" || text == " "), 123 | let context = self.textStorage? 124 | .automaticallyApplyLinkAttribute(self) { 125 | guard self.automaticallyGenerateLinkPreview else { return true } 126 | self.generateLinkPreviewRelay.accept(context) 127 | return false 128 | } 129 | 130 | return true 131 | } 132 | 133 | open func editableTextNodeDidChangeSelection(_ editableTextNode: ASEditableTextNode, 134 | fromSelectedRange: NSRange, 135 | toSelectedRange: NSRange, 136 | dueToEditing: Bool) { 137 | if !dueToEditing { 138 | // NOTE: Move cursor and pick attribute on cursor 139 | let attributes = self.textStorage? 140 | .attributes(at: max(toSelectedRange.location - 1, 0), 141 | effectiveRange: nil) 142 | 143 | // NOTE: Block current location attributes during drag-selection 144 | guard toSelectedRange.length < 1 else { return } 145 | 146 | guard let xmlTags = attributes?[VEditorAttributeKey] as? [String] else { 147 | return 148 | } 149 | self.currentLocationXMLTagsRelay.accept(xmlTags) 150 | self.textStorage?.triggerTouchEventIfNeeds(self) 151 | } else { 152 | guard let textPostion: UITextPosition = 153 | editableTextNode.textView.selectedTextRange?.end else { 154 | return 155 | } 156 | let caretRect: CGRect = editableTextNode.textView.caretRect(for: textPostion) 157 | self.caretRectRelay.accept(caretRect) 158 | } 159 | } 160 | 161 | @discardableResult open func forceFetchCurrentLocationAttribute() -> [String]? { 162 | let attributes = self.textStorage? 163 | .attributes(at: max(self.selectedRange.location - 1, 0), 164 | effectiveRange: nil) 165 | guard let xmlTags = attributes?[VEditorAttributeKey] as? [String] else { 166 | return nil 167 | } 168 | self.currentLocationXMLTagsRelay.accept(xmlTags) 169 | return xmlTags 170 | } 171 | 172 | open func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { 173 | self.textStorage?.didUpdateText(self) 174 | } 175 | 176 | open func updateCurrentTypingAttribute(_ attribute: VEditorStyleAttribute, 177 | isBlock: Bool) { 178 | self.textStorage? 179 | .updateCurrentTypingAttribute(self, 180 | attribute: attribute, 181 | isBlock: isBlock) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorTextStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTextStorage.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | 12 | final public class VEditorTextStorage: NSTextStorage { 13 | 14 | enum TypingStstus { 15 | case typing 16 | case remove 17 | case paste 18 | case none 19 | } 20 | 21 | internal var internalAttributedString: NSMutableAttributedString = .init() { 22 | didSet { 23 | self.internalString = internalAttributedString.string 24 | } 25 | } 26 | internal var internalString: String = "" 27 | 28 | internal var status: TypingStstus = .none 29 | internal var currentTypingAttribute: [NSAttributedString.Key: Any] = [:] 30 | 31 | override public var string: String { 32 | return self.internalString 33 | } 34 | 35 | override public func attributes(at location: Int, 36 | effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key: Any] { 37 | guard self.internalAttributedString.length > location else { return [:] } 38 | return internalAttributedString.attributes(at: location, effectiveRange: range) 39 | } 40 | 41 | override public func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, 42 | range: NSRange) { 43 | guard internalAttributedString.length > range.location else { return } 44 | 45 | switch status { 46 | case .typing, .paste: break 47 | default: return 48 | } 49 | 50 | self.beginEditing() 51 | self.internalAttributedString.setAttributes(attrs, range: range) 52 | self.edited(.editedAttributes, range: range, changeInLength: 0) 53 | self.endEditing() 54 | } 55 | 56 | override public func setAttributedString(_ attrString: NSAttributedString) { 57 | self.status = .paste 58 | super.setAttributedString(attrString) 59 | } 60 | 61 | override public func processEditing() { 62 | switch status { 63 | case .typing: 64 | guard !self.currentTypingAttribute.isEmpty else { break } 65 | self.internalAttributedString 66 | .setAttributes(self.currentTypingAttribute, 67 | range: self.editedRange) 68 | default: 69 | break 70 | } 71 | super.processEditing() 72 | } 73 | 74 | override public func replaceCharacters(in range: NSRange, with str: String) { 75 | if self.status != .paste { 76 | self.status = str.isEmpty ? .remove: .typing 77 | } 78 | 79 | self.beginEditing() 80 | self.internalAttributedString 81 | .replaceCharacters(in: range, with: str) 82 | self.replaceTextString(in: range, with: str) 83 | self.edited(.editedCharacters, 84 | range: range, 85 | changeInLength: str.utf16.count - range.length) 86 | self.endEditing() 87 | } 88 | 89 | internal func replaceTextString(in range: NSRange, with string: String) { 90 | let utf16String = internalString.utf16 91 | let startIndex = utf16String.index(utf16String.startIndex, offsetBy: range.location) 92 | let endIndex = utf16String.index(startIndex, offsetBy: range.length) 93 | internalString.replaceSubrange(startIndex.. NSRange { 153 | return NSString(string: self.internalAttributedString.string) 154 | .paragraphRange(for: range) 155 | } 156 | 157 | 158 | /** 159 | Trigger Regex Pattern Base AttributedText Touch Event Hanlder 160 | 161 | - important: If you wanna use it, try to set VEditorRegexApplierDelegate on textNode, MainThread Only! 162 | 163 | - parameters: 164 | - textNode: VEditorTextNode 165 | - customRange: default is VEditorTextNode selectedRange 166 | 167 | - returns: return touch trigger success status 168 | */ 169 | @discardableResult public func triggerTouchEventIfNeeds(_ textNode: VEditorTextNode, customRange: NSRange? = nil) -> Bool { 170 | guard let regexDelegate = textNode.regexDelegate, !textNode.isEdit else { return false } 171 | let location = (customRange?.location ?? textNode.selectedRange.location) 172 | let attributes = 173 | self.attributes(at: max(0, location - 1), effectiveRange: nil) 174 | 175 | if let url = attributes[.link] as? URL { 176 | regexDelegate.handlURLTouchEvent(url) 177 | return true 178 | } 179 | let patternKeys: [NSAttributedString.Key] = 180 | regexDelegate.allPattern.map({ NSAttributedString.Key.init(rawValue: $0) }) 181 | 182 | for key in patternKeys { 183 | if let value = attributes[key] { 184 | regexDelegate.handlePatternTouchEvent(key.rawValue, value: value) 185 | return true 186 | } 187 | } 188 | 189 | return false 190 | } 191 | 192 | /** 193 | Replace attributedStyle with Regex Pattern 194 | 195 | - important: If you wanna use it, try to set VEditorRegexApplierDelegate on textNode, MainThread Only! 196 | 197 | - parameters: 198 | - textNode: VEditorTextNode 199 | - customRange: default is full internalAttributedString range 200 | 201 | - returns: return matched count with regex pattern 202 | */ 203 | @discardableResult public func replaceAttributeWithRegexPattenIfNeeds(_ textNode: VEditorTextNode, customRange: NSRange? = nil) -> Int { 204 | guard let regexDelegate = textNode.regexDelegate else { return 0 } 205 | let regexs = regexDelegate.allPattern.map({ regexDelegate.regex($0) }) 206 | let range: NSRange = customRange ?? .init(location: 0, 207 | length: self.internalAttributedString.length) 208 | let text: String = self.internalAttributedString.string 209 | var totalMatchedCount: Int = 0 210 | 211 | for regex in regexs { 212 | let matchs: [NSTextCheckingResult] = regex.matches(in: text, options: [], range: range) 213 | 214 | guard !matchs.isEmpty, 215 | let attributedStyle: VEditorStyle = 216 | regexDelegate.paragraphStyle(pattern: regex.pattern) else { continue } 217 | 218 | totalMatchedCount += matchs.count 219 | 220 | for match in matchs { 221 | let matchedRange: NSRange = match.range 222 | 223 | guard let stringRange: Range = 224 | Range.init(matchedRange, in: text) else { continue } 225 | 226 | let patternKey = NSAttributedString.Key.init(rawValue: regex.pattern) 227 | let matchedValue: Any = text[stringRange] as Any 228 | self.internalAttributedString.addAttributes(attributedStyle.attributes, 229 | range: matchedRange) 230 | self.internalAttributedString.addAttribute(patternKey, 231 | value: matchedValue, 232 | range: matchedRange) 233 | } 234 | } 235 | 236 | return totalMatchedCount 237 | } 238 | } 239 | 240 | extension VEditorTextStorage { 241 | 242 | internal func replaceAttributesIfNeeds(_ textNode: VEditorTextNode) { 243 | guard textNode.selectedRange.length > 1 else { return } 244 | 245 | self.status = .paste 246 | self.setAttributes(self.currentTypingAttribute, 247 | range: textNode.selectedRange) 248 | } 249 | 250 | internal func automaticallyApplyLinkAttribute(_ textNode: VEditorTextNode) -> (URL, Int)? { 251 | guard let regex = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return nil } 252 | let blockRange = self.paragraphBlockRange(textNode.selectedRange) 253 | let text: String = self.internalAttributedString.string 254 | 255 | for match in regex.matches(in: text, options: [], range: blockRange) { 256 | 257 | guard let strRange = Range(match.range, in: text) else { 258 | // NOTE: Match not found or failed to generate stringRange 259 | continue 260 | } 261 | 262 | guard textNode.selectedRange.location == match.range.location + match.range.length else { 263 | // NOTE: is not last link 264 | continue 265 | } 266 | 267 | guard case let urlString = String(text[strRange]), let url = URL(string: urlString) else { 268 | // NOTE: Failed to convert URL from string 269 | continue 270 | } 271 | 272 | if let linkXML = textNode.rule.linkStyleXMLTag, 273 | let linkAttribute = textNode.rule.linkAttribute(url) { 274 | self.internalAttributedString.setAttributes(linkAttribute, range: match.range) 275 | } 276 | 277 | return (url, textNode.selectedRange.location) 278 | } 279 | 280 | return nil 281 | } 282 | } 283 | 284 | extension Dictionary where Key == NSAttributedString.Key, Value == Any { 285 | 286 | internal func typingAttribute() -> [String: Any] { 287 | var dict: [String: Any] = [:] 288 | self.forEach({ key, value in 289 | dict[key.rawValue] = value 290 | }) 291 | return dict 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorTypingControlNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorTypingControlNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import RxSwift 12 | import RxCocoa 13 | 14 | open class VEditorTypingControlNode: ASButtonNode { 15 | 16 | public var typingStyle: VEditorStyle 17 | public let xmlTag: String 18 | public let rule: VEditorRule 19 | public let isBlockStyle: Bool 20 | public let isExternalHandler: Bool 21 | 22 | public init(_ xmlTag: String, 23 | rule: VEditorRule, 24 | isBlockStyle: Bool = false, 25 | isExternalHandler: Bool = false) { 26 | self.xmlTag = xmlTag 27 | self.rule = rule 28 | self.isBlockStyle = isBlockStyle 29 | self.isExternalHandler = isExternalHandler 30 | guard let typingStyle = rule.paragraphStyle(xmlTag, attributes: [:]) else { 31 | fatalError("\(xmlTag) doesn't have attributedStyle, Please check your Editor Rule") 32 | } 33 | self.typingStyle = typingStyle 34 | super.init() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorVideoNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorVideoNode.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import RxSwift 12 | import RxCocoa 13 | 14 | open class VEditorVideoNode: VEditorMediaNode { 15 | 16 | public var assetURL: URL? 17 | public var posterURL: URL? 18 | public var videoAsset: AVAsset? { 19 | guard let url = assetURL else { return nil } 20 | return AVURLAsset(url: url) 21 | } 22 | 23 | public required init(isEdit: Bool, 24 | deleteNode: VEditorDeleteMediaNode = .init()) { 25 | super.init(node: .init(), 26 | deleteNode: deleteNode, 27 | isEdit: isEdit) 28 | self.automaticallyManagesSubnodes = true 29 | self.selectionStyle = .none 30 | self.node.backgroundColor = .black 31 | self.node.shouldAutoplay = false 32 | self.node.backgroundColor = .lightGray 33 | self.node.placeholderColor = .lightGray 34 | } 35 | 36 | /** 37 | Set video preview image url 38 | 39 | - parameters: 40 | - url: video preview image url 41 | - returns: self (VEditorVideoNode) 42 | */ 43 | @discardableResult open func setPreviewURL(_ url: URL?) -> Self { 44 | self.node.setURL(url, resetToDefault: true) 45 | return self 46 | } 47 | 48 | /** 49 | Set video asset url 50 | 51 | - parameters: 52 | - url: video asset url 53 | - returns: self (VEditorVideoNode) 54 | */ 55 | @discardableResult open func setAssetURL(_ url: URL?) -> Self { 56 | self.assetURL = url 57 | return self 58 | } 59 | 60 | /** 61 | Set video gravity 62 | 63 | - parameters: 64 | - gravity: video gravity 65 | - returns: self (VEditorVideoNode) 66 | */ 67 | @discardableResult open func setGravity(_ gravity: AVLayerVideoGravity) -> Self { 68 | self.node.contentsGravity = gravity.rawValue 69 | return self 70 | } 71 | 72 | /** 73 | Set video placeholder color 74 | 75 | - parameters: 76 | - color: video placeholder color 77 | - returns: self (VEditorVideoNode) 78 | */ 79 | @discardableResult open func setPlaceholderColor(_ color: UIColor) -> Self { 80 | self.node.placeholderColor = color 81 | return self 82 | } 83 | 84 | /** 85 | Set video background color 86 | 87 | - parameters: 88 | - color: video background color 89 | - returns: self (VEditorVideoNode) 90 | */ 91 | @discardableResult open func setBackgroundColor(_ color: UIColor) -> Self { 92 | self.node.backgroundColor = color 93 | return self 94 | } 95 | 96 | override open func didLoad() { 97 | super.didLoad() 98 | self.node.asset = self.videoAsset 99 | } 100 | 101 | @objc override open func didTapMedia() { 102 | super.didTapMedia() 103 | guard self.isEdit else { return } 104 | 105 | if self.deleteControlNode.isHidden { 106 | self.node.pause() 107 | } else { 108 | self.node.play() 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /VEditorKit/Classes/VEditorXMLBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VEditorXMLBuilder.swift 3 | // VEditorKit 4 | // 5 | // Created by Geektree0101 on 01/02/19. 6 | // Copyright © 2019 Geektree0101. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import BonMot 11 | 12 | public final class VEditorXMLBuilder { 13 | 14 | public static let shared: VEditorXMLBuilder = .init() 15 | public var encodingHTMLEntitiesExternalHandler: ((String) -> String)? 16 | 17 | public func buildXML(_ contents: [VEditorContent], 18 | rule: VEditorRule, 19 | packageTag: String?) -> String? { 20 | 21 | var xmlString: String = "" 22 | 23 | for content in contents { 24 | switch content { 25 | case let mediaContent as VEditorMediaContent: 26 | let tag = mediaContent.xmlTag 27 | let attributes = mediaContent.parseAttributeToXML() 28 | xmlString += self.generateXMLTag(tag, scope: .selfClosing(attributes)) 29 | case let attributedText as NSAttributedString: 30 | xmlString += self.parseAttributedStringToXML(attributedText, rule: rule) 31 | default: 32 | break 33 | } 34 | } 35 | 36 | xmlString = xmlString.squeezXMLString(rule) 37 | 38 | for xml in rule.allXML { 39 | let open = self.generateXMLTag(xml, scope: .open(nil)) 40 | let close = self.generateXMLTag(xml, scope: .close) 41 | let emptyContent = open + close 42 | let duplicatedPairedXMLTags = close + open 43 | xmlString = xmlString 44 | .replacingOccurrences(of: emptyContent, with: "") 45 | .replacingOccurrences(of: duplicatedPairedXMLTags, with: "") 46 | } 47 | 48 | if xmlString.isEmpty { 49 | return nil 50 | } else if let packageTag = packageTag { 51 | return self.packageXML(packageTag, content: xmlString) 52 | } else { 53 | return xmlString 54 | } 55 | } 56 | 57 | enum PackgingScope { 58 | 59 | case selfClosing([String: String]) 60 | case open([String: String]?) 61 | case close 62 | } 63 | 64 | private func parseAttributedStringToXML(_ attrText: NSAttributedString, 65 | rule: VEditorRule) -> String { 66 | var xmlString: String = "" 67 | let range = NSRange(location: 0, length: attrText.length) 68 | 69 | xmlString += self.generateXMLTag(rule.defaultStyleXMLTag, scope: .open(nil)) 70 | 71 | attrText 72 | .enumerateAttributes(in: range, 73 | options: [], 74 | using: { attributes, subRange, _ in 75 | 76 | var content = attrText.attributedSubstring(from: subRange).string 77 | content = content.encodingHTMLEntities() 78 | 79 | guard !content.isEmpty else { return } 80 | 81 | guard let tags = (attributes[VEditorAttributeKey] as? [String])? 82 | .filter({ $0 != rule.defaultStyleXMLTag }) else { 83 | xmlString += content 84 | return 85 | } 86 | 87 | let isBlockStyle: Bool = tags.contains(where: { rule.blockStyleXMLTags.contains($0) }) 88 | 89 | let openTag = tags 90 | .map({ tag -> String in 91 | let attrs = 92 | rule.parseAttributeToXML(tag, 93 | attributes: attributes) 94 | return generateXMLTag(tag, scope: .open(attrs)) 95 | }).joined() 96 | 97 | let closeTag = tags 98 | .reversed() 99 | .map({ tag -> String in 100 | return generateXMLTag(tag, scope: .close) 101 | }).joined() 102 | 103 | let inlineXMLString: String = [openTag, content, closeTag].joined() 104 | 105 | if isBlockStyle { 106 | xmlString += [self.generateXMLTag(rule.defaultStyleXMLTag, scope: .close), 107 | inlineXMLString, 108 | self.generateXMLTag(rule.defaultStyleXMLTag, scope: .open(nil))].joined() 109 | } else { 110 | xmlString += inlineXMLString 111 | } 112 | }) 113 | 114 | 115 | xmlString += self.generateXMLTag(rule.defaultStyleXMLTag, scope: .close) 116 | 117 | return xmlString 118 | } 119 | 120 | private func generateXMLTag(_ xmlTag: String, scope: PackgingScope) -> String { 121 | 122 | switch scope { 123 | case .selfClosing(let attrs): 124 | return "<\(xmlTag) \(attrs.xmlAttributeToString()) />" 125 | case .open(let attrs): 126 | if let attrs = attrs, !attrs.isEmpty { 127 | return "<\(xmlTag) \(attrs.xmlAttributeToString())>" 128 | } else { 129 | return "<\(xmlTag)>" 130 | } 131 | case .close: 132 | return "" 133 | } 134 | } 135 | 136 | private func packageXML(_ xmlTag: String, content: String) -> String { 137 | return ["<\(xmlTag)>", content, ""].joined() 138 | } 139 | } 140 | 141 | extension String { 142 | 143 | internal func squeezXMLString(_ rule: VEditorRule) -> String { 144 | var xmlString: String = self 145 | let squeezTargetTags: [String] = rule.allXML.map({ "<\($0)>" }) 146 | for targetTag in squeezTargetTags { 147 | xmlString = xmlString.replacingOccurrences(of: targetTag, with: "") 148 | } 149 | return xmlString 150 | } 151 | 152 | internal func encodingHTMLEntities() -> String { 153 | guard let handler = VEditorXMLBuilder.shared.encodingHTMLEntitiesExternalHandler else { 154 | // NOTE: default encoding HTML Entities for VEditor 155 | // ref: https://dev.w3.org/html5/html-author/charref 156 | return self.replacingOccurrences(of: "&", with: "&") 157 | .replacingOccurrences(of: "<", with: "<") 158 | .replacingOccurrences(of: ">", with: ">") 159 | .replacingOccurrences(of: "\"", with: """) 160 | .replacingOccurrences(of: "'", with: "'") 161 | .replacingOccurrences(of: "'", with: "9") 162 | .replacingOccurrences(of: "'", with: "’") 163 | .replacingOccurrences(of: "'", with: "–") 164 | } 165 | 166 | return handler(self) 167 | } 168 | 169 | internal func convertDuplicatedBackSlashToValidParserXML() -> String { 170 | return self.replacingOccurrences(of: "\\n", with: "\n") 171 | .replacingOccurrences(of: "\\", with: "") 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /_Pods.xcodeproj: -------------------------------------------------------------------------------- 1 | Example/Pods/Pods.xcodeproj -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: yes 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "70...100" 9 | 10 | status: 11 | project: yes 12 | patch: yes 13 | changes: no 14 | 15 | parsers: 16 | gcov: 17 | branch_detection: 18 | conditional: yes 19 | loop: yes 20 | method: no 21 | macro: no 22 | 23 | comment: 24 | layout: "header, diff" 25 | behavior: default 26 | require_changes: no 27 | -------------------------------------------------------------------------------- /screenshots/english.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/english.gif -------------------------------------------------------------------------------- /screenshots/intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/intro.png -------------------------------------------------------------------------------- /screenshots/korean.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/korean.gif -------------------------------------------------------------------------------- /screenshots/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/logo.png -------------------------------------------------------------------------------- /screenshots/memory_usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/memory_usage.png -------------------------------------------------------------------------------- /screenshots/placeholder.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/placeholder.gif -------------------------------------------------------------------------------- /screenshots/quick_start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/quick_start.png -------------------------------------------------------------------------------- /screenshots/regexAttributeTyping.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/regexAttributeTyping.gif -------------------------------------------------------------------------------- /screenshots/resource1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/resource1.jpg -------------------------------------------------------------------------------- /screenshots/resource2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/resource2.jpg -------------------------------------------------------------------------------- /screenshots/test.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/test.mp4 -------------------------------------------------------------------------------- /screenshots/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/test.png -------------------------------------------------------------------------------- /screenshots/test2.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/test2.mp4 -------------------------------------------------------------------------------- /screenshots/test2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/test2.png -------------------------------------------------------------------------------- /screenshots/test3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/test3.gif -------------------------------------------------------------------------------- /screenshots/test4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/test4.gif -------------------------------------------------------------------------------- /screenshots/typingControl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekTree0101/VEditorKit/84a64caffadfab441fa44abe9723f492e295a572/screenshots/typingControl.gif --------------------------------------------------------------------------------