Skip to content

Commit

Permalink
Added ability to be notified on list indentation changes via delegate
Browse files Browse the repository at this point in the history
  • Loading branch information
rajdeep committed Nov 22, 2023
1 parent 231990d commit 9961b7f
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 20 deletions.
14 changes: 14 additions & 0 deletions Proton/Sources/Swift/Core/ListFormattingProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,18 @@ public protocol EditorListFormattingProvider: AnyObject {
/// - Note: This function is called multiple times for same index level based on TextKit layout cycles. It is advisable to cache
/// the values if calculation/drawing is performance intensive.
func listLineMarkerFor(editor: EditorView, index: Int, level: Int, previousLevel: Int, attributeValue: Any?) -> ListLineMarker

/// Invoked before the indentation level is changed. This may be used to change the list attribute value, if needed.
/// - Parameters:
/// - editor: Editor in which indentation is going to be changed.
/// - range: Range of affected text
/// - currentLevel: Current nested level of list, with first level being 1.
/// - indentMode: Indentation action to be performed
/// - latestAttributeValueAtProposedLevel: List item attribute value immediately preceding the current item at the new level that the current item will be indented to.
/// The value is`nil` if preceding item does not exist at the new level.
func willChangeListIndentation(editor: EditorView, range: NSRange, currentLevel: Int, indentMode: Indentation, latestAttributeValueAtProposedLevel: Any?)
}

public extension EditorListFormattingProvider {
func willChangeListIndentation(editor: EditorView, range: NSRange, currentLevel: Int, indentMode: Indentation, latestAttributeValueAtProposedLevel: Any?) { }
}
38 changes: 22 additions & 16 deletions Proton/Sources/Swift/Core/ListParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,17 @@ public struct ListParser {
/// - 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
/// - Returns: Array of list items with corresponding range in attributedString along with `listIndex` denoting the index of list in the complete text. All items in the same list will have same index.
///`listIndex` may be used to distinguish items of one list from another.
/// - 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)]()
public static func parse(attributedString: NSAttributedString, indent: CGFloat = 25) -> [(listIndex: Int, range: NSRange, listItem: ListItem)] {
var items = [(listIndex: Int, range: NSRange, listItem: ListItem)]()
var counter = 1
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))
let listItems = parseList(in: attributedString.attributedSubstring(from: range), rangeInOriginalString: range, indent: indent, attributeValue: value)
items.append(contentsOf: listItems.map {(listIndex: counter, range: $0.range, listItem: $0.listItem)})
counter += 1
}
}
return items
Expand All @@ -110,35 +114,34 @@ public struct ListParser {
let text = attributedString.attributedSubstring(from: paraRange)
var lines = listLinesFrom(text: text)//text.string.components(separatedBy: .newlines)

if lines.last?.string.isEmpty ?? false {
if lines.last?.text.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 itemRange = line.range
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))
items.append((range: lastItem.range.shiftedBy(paraRange.location + 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))
items.append((itemRange.shiftedBy(paraRange.location + 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]()
private static func listLinesFrom(text: NSAttributedString) -> [(text: NSAttributedString, range: NSRange)] {
var listItems = [(text: NSAttributedString, range: NSRange)]()

let newlineRanges = text.rangesOf(characterSet: .newlines)
var startIndex = 0
Expand All @@ -150,13 +153,16 @@ public struct ListParser {
continue
}

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

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

let range = NSRange(location: startIndex, length: text.length - startIndex)
let itemText = text.attributedSubstring(from: range)
listItems.append((text: itemText, range: range))
return listItems
}
}
16 changes: 12 additions & 4 deletions Proton/Sources/Swift/Editor/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,18 @@ open class EditorView: UIView {
richTextView.scrollRectToVisible(rect, animated: animated)
}

/// Invalidates the display of content at the given range.
/// - Parameter range: Range to invalidate.
public func invalidateDisplay(for range: NSRange) {
richTextView.invalidateDisplay(for: range)
}

/// Invalidates the layout of content at the given range. This will also fore layout of any `Attachment` contained in the given range.
/// - Parameter range: Range to invalidate.
public func invalidateLayout(for range: NSRange) {
richTextView.invalidateLayout(for: range)
}

/// Gets the contents within the `Editor`.
/// - Parameter range: Range to be enumerated to get the contents. If no range is specified, entire content range is
/// enumerated.
Expand Down Expand Up @@ -1343,10 +1355,6 @@ extension EditorView: RichTextViewDelegate {
}

extension EditorView {
func invalidateLayout(for range: NSRange) {
richTextView.invalidateLayout(for: range)
}

func relayoutAttachments(in range: NSRange? = nil) {
let rangeToUse = range ?? NSRange(location: 0, length: contentLength)
richTextView.enumerateAttribute(.attachment, in: rangeToUse, options: .longestEffectiveRangeNotRequired) { [weak self] (attach, range, _) in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ public class ListTextProcessor: TextProcessing {

for line in lines {
if line.text.length == 0 || line.text.attribute(.listItem, at: 0, effectiveRange: nil) == nil {
var paraStyle: NSParagraphStyle? = nil
var range = line.range
if let prevCharRange = line.range.previousCharacterRange,
prevCharRange.isValidIn(editor.textInput) {
paraStyle = editor.attributedText.attribute(.paragraphStyle, at: prevCharRange.location, effectiveRange: nil) as? NSParagraphStyle
range = prevCharRange
}

notifyIndentationChange(editor: editor, paraStyle: paraStyle, lineRange: range, indentMode: indentMode)
createListItemInANewLine(editor: editor, editedRange: line.range, indentMode: indentMode, attributeValue: attributeValue)
continue
}
Expand All @@ -214,6 +223,8 @@ public class ListTextProcessor: TextProcessing {
return
}

notifyIndentationChange(editor: editor, paraStyle: paraStyle, lineRange: line.range, indentMode: indentMode)

editor.addAttribute(.paragraphStyle, value: mutableStyle ?? editor.paragraphStyle, at: line.range)

// Remove listItem attribute if indented all the way back
Expand All @@ -224,10 +235,26 @@ public class ListTextProcessor: TextProcessing {
editor.removeAttribute(.listItem, at: NSRange(location: previousLine.range.endLocation, length: 1))
}
}


indentChildLists(editor: editor, editedRange: line.range, originalParaStyle: paraStyle, indentMode: indentMode)
}
}

private func notifyIndentationChange(editor: EditorView, paraStyle: NSParagraphStyle?, lineRange: NSRange, indentMode: Indentation) {
let currentLevel = Int((paraStyle ?? editor.paragraphStyle).firstLineHeadIndent/editor.listLineFormatting.indentation)
var latestAttributeValueAtProposedLevel: Any?
let newLevel = CGFloat(indentMode == .indent ? currentLevel + 1 : currentLevel - 1) * editor.listLineFormatting.indentation
editor.attributedText.enumerateAttribute(.listItem, in: NSRange(location: 0, length: lineRange.endLocation), options: [.reverse, .longestEffectiveRangeNotRequired]) { attrValue, range, stop in
if let paragraphStyle = editor.attributedText.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle,
paragraphStyle.firstLineHeadIndent == newLevel {
latestAttributeValueAtProposedLevel = attrValue
stop.pointee = true
}
}
editor.listFormattingProvider?.willChangeListIndentation(editor: editor, range: lineRange, currentLevel: currentLevel, indentMode: indentMode, latestAttributeValueAtProposedLevel: latestAttributeValueAtProposedLevel)
}

private func indentChildLists(editor: EditorView, editedRange: NSRange, originalParaStyle: NSParagraphStyle?, indentMode: Indentation) {
var subListRange = NSRange.zero
guard let nextLine = editor.nextContentLine(from: editedRange.location),
Expand Down
49 changes: 49 additions & 0 deletions Proton/Tests/Core/ListParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,60 @@ class ListParserTests: XCTestCase {
XCTAssertEqual(list[0].listItem.text.string, line1.string)
XCTAssertEqual(list[1].listItem.text.string, line3.string)

XCTAssertEqual(list[0].listIndex, 1)
XCTAssertEqual(list[1].listIndex, 2)


XCTAssertEqual(list[0].listItem.level, 1)
XCTAssertEqual(list[1].listItem.level, 1)

XCTAssertEqual(list[0].range, NSRange(location: 0, length: 63))
XCTAssertEqual(list[1].range, NSRange(location: 80, length: 47))
}

func testFullCircleWithSameAttributeValue() {
let listItems1 = [
ListItem(text: NSAttributedString(string: "One"), level: 1, attributeValue: 1),
ListItem(text: NSAttributedString(string: "Two"), level: 1, attributeValue: 1),
ListItem(text: NSAttributedString(string: "Three"), level: 2, attributeValue: 1),
ListItem(text: NSAttributedString(string: "Four"), level: 2, attributeValue: 1),
ListItem(text: NSAttributedString(string: "Five"), level: 2, attributeValue: 1),
ListItem(text: NSAttributedString(string: "Six"), level: 1, attributeValue: 1)
]

let listItems2 = [
ListItem(text: NSAttributedString(string: "A"), level: 1, attributeValue: 1),
ListItem(text: NSAttributedString(string: "B"), level: 1, attributeValue: 1),
ListItem(text: NSAttributedString(string: "C"), level: 2, attributeValue: 1),
ListItem(text: NSAttributedString(string: "D"), level: 2, attributeValue: 1),
ListItem(text: NSAttributedString(string: "E"), level: 2, attributeValue: 1),
ListItem(text: NSAttributedString(string: "F"), level: 1, attributeValue: 1)
]

let string1 = ListParser.parse(list: listItems1, indent: 25)
let string2 = ListParser.parse(list: listItems2, indent: 25)

let string = NSMutableAttributedString()
string.append(string1)
string.append(NSAttributedString(string: "Some other text \n"))
string.append(string2)

let convertedItems = ListParser.parse(attributedString: string)
let list1 = convertedItems.filter { $0.listIndex == 1 }
let list2 = convertedItems.filter { $0.listIndex == 2 }

XCTAssertEqual(convertedItems.count, 12)
XCTAssertEqual(list1.count, 6)
XCTAssertEqual(list2.count, 6)

for i in 0..<list1.count {
XCTAssertEqual(convertedItems[i].listItem.text.string, list1[i].listItem.text.string)
}

var convertedIndex = list1.count
for i in 0..<list2.count {
XCTAssertEqual(convertedItems[convertedIndex].listItem.text.string, list2[i].listItem.text.string)
convertedIndex += 1
}
}
}
24 changes: 24 additions & 0 deletions Proton/Tests/Core/Mocks/MockListFormattingProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,27 @@ class MockListFormattingProvider: EditorListFormattingProvider {
return value
}
}

class MockMixedListFormattingProvider: EditorListFormattingProvider {
enum ListType: String {
case ordered
case unordered
}

let listLineFormatting: LineFormatting
var onListMarkerForItem : ((EditorView, Int, Int, Int, Any) -> Void)?


init(listLineFormatting: LineFormatting? = nil) {
self.listLineFormatting = listLineFormatting ?? LineFormatting(indentation: 25, spacingBefore: 0)
}

func listLineMarkerFor(editor: EditorView, index: Int, level: Int, previousLevel: Int, attributeValue: Any?) -> ListLineMarker {
let isOrdered = (attributeValue as? String) == ListType.ordered.rawValue
let sequenceGenerator: SequenceGenerator = isOrdered ? NumericSequenceGenerator() : DiamondBulletSequenceGenerator()
let value = sequenceGenerator.value(at: index)

onListMarkerForItem?(editor, index, level, previousLevel, attributeValue ?? "*")
return value
}
}
1 change: 1 addition & 0 deletions Proton/Tests/Editor/EditorListsSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class EditorListsSnapshotTests: SnapshotTestCase {
let listCommand = ListCommand()
let listTextProcessor = ListTextProcessor()
let listFormattingProvider = MockListFormattingProvider()
let mixedListFormattingProvider = MockMixedListFormattingProvider()

override func setUp() {
super.setUp()
Expand Down

0 comments on commit 9961b7f

Please sign in to comment.