Upgrade to Pro — share decks privately, control downloads, hide ads and more …

君だけのGFMエディタを作ろう! / iOSDC Japan 2023

FromAtom
September 02, 2023

君だけのGFMエディタを作ろう! / iOSDC Japan 2023

iOSDC Japan 2023 レギュラートーク(20分)
2023/09/02 16:50〜Track C

https://fortee.jp/iosdc-japan-2023/proposal/17181b9b-3c8f-471f-93db-da377ad90ece

FromAtom

September 02, 2023
Tweet

More Decks by FromAtom

Other Decks in Programming

Transcript

  1. GitHub Flavored Markdownͱ͸ʁ • CommonMarkΛ֦ுͨ͠ͷ͕GitHub Flavored MarkdownʢGFMʣ • GitHub্Ͱ࢖͑ΔMarkdownͱࣅͨ΋ͷ •

    ݫີʹ͸Լهͷ఺͕ҟͳ͍ͬͯΔ ‣ ֆจࣈ (ex. :smile:) ‣ ϝϯγϣϯ ‣ ίϛοτʢίϛοτIDʣɾΠγϡʔɾϦϙδτϦͷࢀর 7
  2. swift-markdownͱ͸ʁ 32 import Markdown let source = "This is a

    markup *document*." let document = Document(parsing: source) print(document.debugDescription()) // Document // └─ Paragraph // ├─ Text "This is a markup " // ├─ Emphasis // │ └─ Text "document" // └─ Text "."
  3. 34 @available(iOS 3.2, *) @NSCopying var selectedTextRange: UITextRange? { get

    set } open var selectedRange: NSRange ΧʔιϧͷҐஔΛऔಘ͢Δ 1 OR
  4. • swift-markdownͰ͸ൣғΛࣔ͢ͷʹSourceRange͕࢖ΘΕΔ • ΧʔιϧҐஔΛSourceRangeʹม׵͢Δඞཁ͕͋Δ 35 /// A range in a

    source file. public typealias SourceRange = Range<SourceLocation> ΧʔιϧҐஔΛswift-markdown͕ѻ͑ΔΑ͏ʹม׵͢Δ 2
  5. 36 /// A location in a source file. public struct

    SourceLocation: Hashable, CustomStringConvertible, Comparable { public static func < (lhs: SourceLocation, rhs: SourceLocation) -> Bool { if lhs.line < rhs.line { return true } else if lhs.line == rhs.line { return lhs.column < rhs.column } else { return false } } /// The line number of the location. public var line: Int /// The number of bytes in UTF-8 encoding from the start of the line to the character at this source location. public var column: Int /// The source file for which this location applies, if it came from an accessible location. public var source: URL? /// Create a source location with line, column, and optional source to which the location applies. /// /// - parameter line: The line number of the location, starting with 1. /// - parameter column: The column of the location, starting with 1. /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific /// file or resource that needs to be identified. public init(line: Int, column: Int, source: URL?) { self.line = line self.column = column self.source = source } public var description: String { let path = source.map { $0.path.isEmpty ? "" : "\($0.path):" } ?? "" return "\(path)\(line):\(column)" } } SourceLocationͷ࣮૷
  6. 37 public struct SourceLocation: Hashable, CustomStringConvertible, Comparable { /// Create

    a source location with line, column, and optional source to which the location applies. /// /// - parameter line: The line number of the location, starting with 1. /// - parameter column: The column of the location, starting with 1. /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific /// file or resource that needs to be identified. public init(line: Int, column: Int, source: URL?) { self.line = line self.column = column self.source = source } }
  7. 38 public struct SourceLocation: Hashable, CustomStringConvertible, Comparable { /// Create

    a source location with line, column, and optional source to which the location applies. /// /// - parameter line: The line number of the location, starting with 1. /// - parameter column: The column of the location, starting with 1. /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific /// file or resource that needs to be identified. public init(line: Int, column: Int, source: URL?) { self.line = line self.column = column self.source = source } }
  8. 40 public struct SourceLocation: Hashable, CustomStringConvertible, Comparable { /// The

    line number of the location. public var line: Int /// The number of bytes in UTF-8 encoding from the start of the line to the character at this source location. public var column: Int /// Create a source location with line, column, and optional source to which the location applies. /// /// - parameter line: The line number of the location, starting with 1. /// - parameter column: The column of the location, starting with 1. /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific /// file or resource that needs to be identified. public init(line: Int, column: Int, source: URL?) { self.line = line self.column = column self.source = source } }
  9. ΧʔιϧҐஔ 43 @NSCopying var selectedTextRange: UITextRange? { get set }

    textView.selectedRange.location // 文章全体のn文字目 textView.selectedRange.length // locationからm文字目 textView.selectedTextRange?.start // 文章の先頭からn文字目 textView.selectedTextRange?.end // 文章の先頭からm文字目 open var selectedRange: NSRange
  10. 46 import UIKit import Markdown extension UITextView { func sourceLocation()

    -> SourceLocation? { guard let selectedTextRange else { return nil } // ドキュメントの先頭からカーソル位置までの文字列を取得 guard let beginningOfDocumentToCursorPositionRange = textRange(from: beginningOfDocument, to: selectedTextRange.start), let beginningOfDocumentToCursorPositionString = text(in: beginningOfDocumentToCursorPositionRange) else { return nil } let lineCount = beginningOfDocumentToCursorPositionString.components(separatedBy: .newlines).count // カーソルがある行の行頭からカーソル位置までの文字列を取得 guard let lineRange = tokenizer.rangeEnclosingPosition(selectedTextRange.start, with: .line, inDirection: .storage(.forward)), let columnStringRange = textRange(from: lineRange.start, to: selectedTextRange.start), let columnString = text(in: columnStringRange) else { return nil } // SourceLocationのcolumnはUTF-8でカウントする // SourceLocationはカウントが1から始まるので1を足す let utf8BasedColumnCount = columnString.utf8.count + 1 return SourceLocation(line: lineCount, column: utf8BasedColumnCount, source: nil) } } ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ ※ϓϩμΫγϣϯͰ࣮૷͢Δ৔߹͸UITextViewΛExtension͠ͳ͍΄͏͕ྑ͍Α UITextRange͔ΒSourceLocationʹม׵͢Δίʔυ
  11. MarkupWalkerΛ࢖͏ 47 import Markdown struct StrongWalker: MarkupWalker { var isStrong

    = false var cursorSourceLocation: SourceLocation mutating func visitStrong(_ strong: Strong) -> () { if let range = strong.range, range.contains(cursorSourceLocation) { isStrong = true return } descendInto(strong) } } Visitor patternʹै࣮ͬͯ૷ ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ 3
  12. MarkupWalkerΛ࢖͏ 48 import Markdown struct StrongWalker: MarkupWalker { var isStrong

    = false var cursorSourceLocation: SourceLocation mutating func visitStrong(_ strong: Strong) -> () { if let range = strong.range, range.contains(cursorSourceLocation) { isStrong = true return } descendInto(strong) } } Visitor patternʹै࣮ͬͯ૷ ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ 3
  13. MarkupWalkerΛ࢖͏ 49 import Markdown struct StrongWalker: MarkupWalker { var isStrong

    = false var cursorSourceLocation: SourceLocation mutating func visitStrong(_ strong: Strong) -> () { if let range = strong.range, range.contains(cursorSourceLocation) { isStrong = true return } descendInto(strong) } } Visitor patternʹै࣮ͬͯ૷ ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ 3
  14. 50 guard let cursorSourceLocation = textView.sourceLocation() else { return }

    let document = Document(parsing: textView.text) var strongWalker = StrongWalker(cursorSourceLocation: cursorSourceLocation) strongWalker.visit(document) print(strongWalker.isStrong) // カーソルが太字だとtrue ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ 3
  15. ΧʔιϧҐஔͷଠࣈΛղআ 53 struct StrongRemover: MarkupRewriter { var cursorSourceLocation: SourceLocation func

    visitStrong(_ strong: Strong) -> Markup? { if let range = strong.range, range.contains(cursorSourceLocation) { return Text(strong.plainText) } return strong } } var strongRemover = StrongRemover(cursorSourceLocation: cursorSourceLocation) let result = strongRemover.visit(document) textView.text = result!.format() ※ෳࡶͳMarkdownʹରԠ͢Δʹ͸Text(strong.plainText)Λฦ͢ͷͰ͸ͳ͘ɺஸೡͳॲཧ͕ඞཁͰ͢ MarkupRewriterΛར༻͢Δ
  16. 57 struct AttributedStringWalker: MarkupWalker { var strongSourceRanges: [SourceRange] = []

    mutating func visitStrong(_ strong: Strong) -> () { if let range = strong.range { strongSourceRanges.append(range) } descendInto(strong) } } 1 MarkupWalkerͰଠࣈه๏ͷSourceRangeΛऔಘ
  17. 58 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
  18. 59 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
  19. 60 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
  20. 61 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
  21. 62 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
  22. 63 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ SourceLocation: UTF-8 NSAttributedString: UTF-16 ref: https://developer.apple.com/documentation/foundation/nsattributedstring/1418432-length Χ΢ϯτํ๏ͷҧ͍ʹ஫ҙ
  23. 64 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ SourceLocation: UTF-8 NSAttributedString: UTF-16 ref: https://developer.apple.com/documentation/foundation/nsattributedstring/1418432-length Χ΢ϯτํ๏ͷҧ͍ʹ஫ҙ
  24. 65 extension UITextView { func convertToNSRange(from sourceRange: SourceRange) -> NSRange?

    { guard let start = convertToBound(from: sourceRange.lowerBound), let end = convertToBound(from: sourceRange.upperBound) else { return nil } return NSRange(start..<end) } private func convertToBound(from sourceLocation: SourceLocation) -> Int? { var currentLine: Int = 1 var utf16CharCount: Int = 0 for line in text.components(separatedBy: .newlines) { if currentLine == sourceLocation.line { let columnString = line.utf8.prefix(sourceLocation.column - 1) let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line) return utf16CharCount + columnStringUtf16Count } currentLine += 1 utf16CharCount += line.utf16.count + "\n".utf16.count } return nil } } SourceRangeΛNSRangeʹม׵ 2 ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
  25. 66 let document = Document(parsing: textView.text) var strongWalker = AttributedStringWalker()

    strongWalker.visit(document) let attributedString = NSMutableAttributedString(string: textView.text) for strongRange in strongWalker.strongSourceRanges { let range = textView.convertToNSRange(from: strongRange)! attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range) } 
 textView.attributedText = attributedString NSAttributedStringΛੜ੒ 3
  26. 67 let document = Document(parsing: textView.text) var strongWalker = AttributedStringWalker()

    strongWalker.visit(document) let attributedString = NSMutableAttributedString(string: textView.text) for strongRange in strongWalker.strongSourceRanges { let range = textView.convertToNSRange(from: strongRange)! attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range) } 
 textView.attributedText = attributedString NSAttributedStringΛੜ੒ 3
  27. 68 let document = Document(parsing: textView.text) var strongWalker = AttributedStringWalker()

    strongWalker.visit(document) let attributedString = NSMutableAttributedString(string: textView.text) for strongRange in strongWalker.strongSourceRanges { let range = textView.convertToNSRange(from: strongRange)! attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range) } 
 textView.attributedText = attributedString NSAttributedStringΛੜ੒ 3
  28. 69 let document = Document(parsing: textView.text) var strongWalker = AttributedStringWalker()

    strongWalker.visit(document) let attributedString = NSMutableAttributedString(string: textView.text) for strongRange in strongWalker.strongSourceRanges { let range = textView.convertToNSRange(from: strongRange)! attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range) } 
 textView.attributedText = attributedString NSAttributedStringΛੜ੒ 3
  29. 70 let document = Document(parsing: textView.text) var strongWalker = AttributedStringWalker()

    strongWalker.visit(document) let attributedString = NSMutableAttributedString(string: textView.text) for strongRange in strongWalker.strongSourceRanges { let range = textView.convertToNSRange(from: strongRange)! attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range) } 
 textView.attributedText = attributedString NSAttributedStringΛੜ੒ 3
  30. 71 let document = Document(parsing: textView.text) var strongWalker = AttributedStringWalker()

    strongWalker.visit(document) let attributedString = NSMutableAttributedString(string: textView.text) for strongRange in strongWalker.strongSourceRanges { let range = textView.convertToNSRange(from: strongRange)! attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range) } 
 textView.attributedText = attributedString NSAttributedStringΛੜ੒ 3