Skip to content

Commit

Permalink
List parser (#84)
Browse files Browse the repository at this point in the history
Implemented helper object to allow for parsing between List and NSAttributedString - in either direction #52
  • Loading branch information
rajdeep authored Dec 15, 2020
1 parent 7e9e8e5 commit 09e0103
Show file tree
Hide file tree
Showing 7 changed files with 509 additions and 1 deletion.
16 changes: 16 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"object": {
"pins": [
{
"package": "SnapshotTesting",
"repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing.git",
"state": {
"branch": "master",
"revision": "641ad58daf62c4577dbcb23a80b1e437a459daf6",
"version": null
}
}
]
},
"version": 1
}
8 changes: 8 additions & 0 deletions Proton/Proton.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@
1BE88262250E3ABB00E2CC1B /* PREditorContentName.h in Headers */ = {isa = PBXBuildFile; fileRef = 1BE88261250E3ABB00E2CC1B /* PREditorContentName.h */; };
1BE88264250E3ADB00E2CC1B /* PREditorContentName.m in Sources */ = {isa = PBXBuildFile; fileRef = 1BE88263250E3ADB00E2CC1B /* PREditorContentName.m */; };
1BF90FB4245E503E00A411A3 /* TextBlockAttributeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF90FB3245E503E00A411A3 /* TextBlockAttributeTests.swift */; };
1BFDC80F254A9BFC00BD83BD /* ListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BFDC80E254A9BFC00BD83BD /* ListParser.swift */; };
1BFDC811254AA11F00BD83BD /* ListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BFDC810254AA11F00BD83BD /* ListParserTests.swift */; };
1BFFEF0523C308BF00D2BA35 /* MockAutogrowingTextViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BFFEF0423C308BF00D2BA35 /* MockAutogrowingTextViewDelegate.swift */; };
1BFFEF0C23C30D5200D2BA35 /* EditorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BFFEF0B23C30D5200D2BA35 /* EditorContentView.swift */; };
1BFFEF1523C332A100D2BA35 /* EditorSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BFFEF1423C332A100D2BA35 /* EditorSnapshotTests.swift */; };
Expand Down Expand Up @@ -263,6 +265,8 @@
1BE88261250E3ABB00E2CC1B /* PREditorContentName.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PREditorContentName.h; sourceTree = "<group>"; };
1BE88263250E3ADB00E2CC1B /* PREditorContentName.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PREditorContentName.m; sourceTree = "<group>"; };
1BF90FB3245E503E00A411A3 /* TextBlockAttributeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBlockAttributeTests.swift; sourceTree = "<group>"; };
1BFDC80E254A9BFC00BD83BD /* ListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListParser.swift; sourceTree = "<group>"; };
1BFDC810254AA11F00BD83BD /* ListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListParserTests.swift; sourceTree = "<group>"; };
1BFFEF0423C308BF00D2BA35 /* MockAutogrowingTextViewDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockAutogrowingTextViewDelegate.swift; sourceTree = "<group>"; };
1BFFEF0B23C30D5200D2BA35 /* EditorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorContentView.swift; sourceTree = "<group>"; };
1BFFEF1423C332A100D2BA35 /* EditorSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSnapshotTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -451,6 +455,7 @@
1BE8825E250DD7B600E2CC1B /* PRTextStorage.m */,
1BE88261250E3ABB00E2CC1B /* PREditorContentName.h */,
1BE88263250E3ADB00E2CC1B /* PREditorContentName.m */,
1BFDC80E254A9BFC00BD83BD /* ListParser.swift */,
);
path = Core;
sourceTree = "<group>";
Expand All @@ -465,6 +470,7 @@
1B82570523C48FEC0033A0A9 /* RichTextViewContextTests.swift */,
1B45CDDA23C04BE3001EB196 /* RichTextViewSnapshotTests.swift */,
1B4B60C5247F692D002B63CF /* ListsSnapshotTests.swift */,
1BFDC810254AA11F00BD83BD /* ListParserTests.swift */,
);
path = Core;
sourceTree = "<group>";
Expand Down Expand Up @@ -954,6 +960,7 @@
1B45CDD223C00856001EB196 /* TextContainer.swift in Sources */,
1B4B60CA247FC51E002B63CF /* ListCommand.swift in Sources */,
1BBAC3CF23CD5A1B0088A1C8 /* UITextRangeExtensions.swift in Sources */,
1BFDC80F254A9BFC00BD83BD /* ListParser.swift in Sources */,
1B183D8E23CEE9BA00AE83E5 /* AttributesEncoding.swift in Sources */,
1BD21554246951090000BCE2 /* LayoutManager.swift in Sources */,
1BBAC3CC23CD571D0088A1C8 /* RendererViewDelegate.swift in Sources */,
Expand Down Expand Up @@ -1029,6 +1036,7 @@
1BC25B542448768200FF88AC /* EditorContextDelegateTests.swift in Sources */,
1BBAC3E323CE85660088A1C8 /* RendererCommandExecutorTests.swift in Sources */,
1B8D8208243F3386009AD38A /* MockRendererCommand.swift in Sources */,
1BFDC811254AA11F00BD83BD /* ListParserTests.swift in Sources */,
1BD1791E23C589340066DC13 /* EditorCommandExecutorTests.swift in Sources */,
1B82570423C48C350033A0A9 /* MockRichTextViewDelegate.swift in Sources */,
1BBE4EFC248CB15E00D1C788 /* ListCommandTests.swift in Sources */,
Expand Down
150 changes: 150 additions & 0 deletions Proton/Sources/Core/ListParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//
// ListParser.swift
// Proton
//
// Created by Rajdeep Kwatra on 29/10/20.
// Copyright © 2020 Rajdeep Kwatra. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

/// Represents an item in the list. This structure may be used to create `NSAttributedString` from items in an array of `ListItem`. Alternatively, `NSAttributedString` may also be parsed to get an array of `ListItem`s.
public struct ListItem {

/// Text of the list item. All attributes are preserved as is.
/// - Note: If the text contains a newline (`\n`), it is preserved as a newline in text by applying `.skipNextListMarker` attribute.
public let text: NSAttributedString

/// Level of the list item. This is used with indent to get `paragraphStyle` to be applied with appropriate indentation of the list items.
public let level: Int

/// Attribute value of the list item.
public let attributeValue: Any
}

/// Provides helper function to convert between `NSAttributedString` and `[ListItem]`
public struct ListParser {

/// Parses an array of list items into an `NSAttributedString` representation. `NewLines` are automatically added between each list item in the attributed string representation.
/// - Parameters:
/// - list: List items to convert
/// - indent: Indentation to be used. This determines the paragraph indentation for layout.
/// - Returns: NSAttributedString representation of list items
public static func parse(list: [ListItem], indent: CGFloat) -> NSAttributedString {
let attributedString = NSMutableAttributedString()
for i in 0..<list.count {
let item = list[i]
let paraStyle = NSMutableParagraphStyle()
paraStyle.firstLineHeadIndent = CGFloat(item.level) * indent
paraStyle.headIndent = paraStyle.firstLineHeadIndent
let listText = NSMutableAttributedString(attributedString: item.text)
listText.addAttribute(.listItem, value: item.attributeValue, range: listText.fullRange)
listText.addAttribute(.paragraphStyle, value: paraStyle, range: listText.fullRange)
let newLineRanges = listText.rangesOf(characterSet: .newlines)
for newLineRange in newLineRanges {
listText.addAttributes([
.blockContentType:EditorContentName.newline(),
.skipNextListMarker: 1
], range: newLineRange)
}

if i < list.count - 1 {
listText.append(NSAttributedString(string: "\n",
attributes: [
NSAttributedString.Key.blockContentType:EditorContentName.newline(),
NSAttributedString.Key.listItem: item.attributeValue,
NSAttributedString.Key.paragraphStyle: paraStyle
]))
}
attributedString.append(listText)
}
return attributedString
}

/// Parses NSAttributedString to list items
/// - Parameters:
/// - attributedString: NSAttributedString to convert to list items.
/// - indent: Indentation used in list representation in attributedString. This determines the level of list item.
/// - Returns: Array of list items with corresponding range in attributedString
/// - Note: If NSAttributedString passed into the function is non continuous i.e. contains multiple lists, the array will contain items from all the list with the range corresponding to range of text in original attributed string.
public static func parse(attributedString: NSAttributedString, indent: CGFloat = 25) -> [(range: NSRange, listItem: ListItem)] {
var items = [(range: NSRange, listItem: ListItem)]()
attributedString.enumerateAttribute(.listItem, in: attributedString.fullRange, options: []) { (value, range, _) in
if value != nil {
items.append(contentsOf: parseList(in: attributedString.attributedSubstring(from: range), rangeInOriginalString: range, indent: indent, attributeValue: value))
}
}
return items
}

private static func parseList(in attributedString: NSAttributedString, rangeInOriginalString: NSRange, indent: CGFloat, attributeValue: Any?) -> [(range: NSRange, listItem: ListItem)] {
var items = [(range: NSRange, listItem: ListItem)]()

attributedString.enumerateAttribute(.paragraphStyle, in: attributedString.fullRange, options: []) { paraAttribute, paraRange, _ in
if let paraStyle = paraAttribute as? NSParagraphStyle {
let level = Int(paraStyle.headIndent/indent)
let text = attributedString.attributedSubstring(from: paraRange)
var lines = listLinesFrom(text: text)//text.string.components(separatedBy: .newlines)

if lines.last?.string.isEmpty ?? false {
lines.remove(at: lines.count - 1)
}
var start = 0
for i in 0..<lines.count {
let line = lines[i]
let itemRange = NSRange(location: start, length: line.string.count)
let newlineRange = NSRange(location: max(itemRange.location - 1, 0), length: 1)
if newlineRange.endLocation < text.length,
text.attributeValue(for: .skipNextListMarker, at: newlineRange.location) != nil,
var lastItem = items.last {
lastItem.range = NSRange(location: lastItem.range.location, length: itemRange.endLocation)
lastItem.listItem = ListItem(text: text.attributedSubstring(from: lastItem.range), level: level, attributeValue: attributeValue as Any)
items.remove(at: items.count - 1)
items.append((range: lastItem.range.shiftedBy(rangeInOriginalString.location), listItem: lastItem.listItem))
} else {
let listLine = text.attributedSubstring(from: itemRange)
let item = ListItem(text: listLine, level: level, attributeValue: attributeValue as Any)
items.append((itemRange.shiftedBy(rangeInOriginalString.location), item))
}
start += line.string.count + 1 // + 1 to account for \n
}
}
}
return items
}

private static func listLinesFrom(text: NSAttributedString) -> [NSAttributedString] {
var listItems = [NSAttributedString]()

let newlineRanges = text.rangesOf(characterSet: .newlines)
var startIndex = 0

for newlineRange in newlineRanges {
let isNewlineSkipMarker = text.attributedSubstring(from: newlineRange).attribute(.skipNextListMarker, at: 0, effectiveRange: nil) != nil

if isNewlineSkipMarker {
continue
}

let itemText = text.attributedSubstring(from: NSRange(location: startIndex, length: newlineRange.location - startIndex))
listItems.append(itemText)
startIndex = newlineRange.endLocation
}

let itemText = text.attributedSubstring(from: NSRange(location: startIndex, length: text.length - startIndex))
listItems.append(itemText)
return listItems
}
}
4 changes: 3 additions & 1 deletion Proton/Sources/Core/RichTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,9 @@ class RichTextView: AutogrowingTextView {

private func updatePlaceholderVisibility() {
guard self.attributedText.length == 0 else {
placeholderLabel.removeFromSuperview()
if placeholderLabel.superview != nil {
placeholderLabel.removeFromSuperview()
}
return
}
setupPlaceholder()
Expand Down
8 changes: 8 additions & 0 deletions Proton/Sources/Helpers/NSRangeExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,12 @@ public extension NSRange {
let contentLength = textInput.offset(from: textInput.beginningOfDocument, to: textInput.endOfDocument)
return end < contentLength
}

/// Shifts the range with given shift value
/// - Parameter shift: Int value to shift range by.
/// - Returns: Shifted range with same length.
/// - Important: The shifted range may or may not be valid in a given string. Validity of shifted range must always be checked at the usage site.
func shiftedBy(_ shift: Int) -> NSRange {
return NSRange(location: self.location + shift, length: length)
}
}
Loading

0 comments on commit 09e0103

Please sign in to comment.