やりたいこと
Markdown エディタを実装していくと、カーソルのいる場所がリストなのか太字なのか見出しなのかを知りたくなります。単純に記法によって見た目を変えたいのであれば、NSAttributedString.init(markdown:options:baseURL:) を使えばよいです。
一方で、エディタに太字ボタントグルボタン機能(はてなブログ にもありますね)を作っていくと、「いまカーソルがいる場所は太字なのか」が知りたくなります。それが分かると
カーソル箇所が太字の場合:太字記法(****
)を削除する
カーソル箇所が太字ではない場合:太字記法(****
)を挿入する
といった処理を実装することが可能になります。
課題
太字かどうか確認する場合、簡素に考えると
カーソルがある行を取り出す
カーソル前後に文字列を2つに分割する
分割した2つの文字列それぞれに **
があれば太字とする
という実装方法が思いつきます。これでもある程度は動くのですが、このように太字に指定される範囲が複数行にまたがる場合で困ります。
**これは
複数行にまたがる
太字**です
この場合カーソルがいる行だけ見ていては、太字かどうか判定できません。また、
`コードスパンなので**太字ではない**`
のように、使われる記号としては太字ではあるが、太字として解釈しない場合もあります。これらをまともに対応していくと、最終的に文章全体を解析して構文木 を生成するMarkdown パーザを実装することになります。
実装方針
自分でMarkdown パーザを作るのも良いですが、すでにApple 公式が作ったものがあるのでそちらを利用しましょう。
github.com
swift-markdown は cmark-gfm を利用して実装されています。
カーソル位置を取得できるようにする
swift-markdown を使う前に、カーソル位置を取得できるようにしましょう。
import UIKit
final class ViewController : UIViewController {
@IBOutlet weak var textView : UITextView! {
didSet {
textView.delegate = self
}
}
@IBOutlet weak var label : UILabel!
}
extension ViewController : UITextViewDelegate {
func textViewDidChangeSelection (_ textView: UITextView ) {
print(textView.selectedTextRange)
}
}
こうすれば、カーソル位置がUITextRangeとして取得できます。
このカーソル位置をswift-markdown で利用している値に変換する必要があります。swift-markdown では文章のどこに対象が存在しているかを示すために、SourceRangeが使われます。SourceRangeは
public typealias SourceRange = Range < SourceLocation >
https://github.com/apple/swift-markdown/blob/d491147940587dbadfb3472354f4d0c6e063e061/Sources/Markdown/Infrastructure/SourceLocation.swift#L64
と定義されているので、SourceLocationのRangeになります。そんなSourceLocationは
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
}
}
public var line : Int
public var column : Int
public var source : URL?
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) "
}
}
https://github.com/apple/swift-markdown/blob/d491147940587dbadfb3472354f4d0c6e063e061/Sources/Markdown/Infrastructure/SourceLocation.swift#L14
と定義されています。つまり、
line
: 全体の何行目か
column
: 行頭から何文字目か
という要素で対象の場所を示しています。ここでの注意点としては
カウントは0始まりではなく1始まるである
columnはUTF-8 エンコード におけるバイト数を用いる
事です。もうすでに面倒くさい匂いが漂っています。
さて、UITextViewの扱いに慣れた人は感づいていると思いますが、こんなカウント方法をとるRange要素はデフォルトには用意されていません。UITextViewでは UITextVIew.selectedRange
と UITextView.selectedTextRange
でそれぞれ NSRange
と UITextRange
が得られるのですが、それぞれざっくり説明すると
NSRange
location
と length
が得られる
location
: 文章の先頭からn文字目
length
: locationからm文字分(範囲選択してないなら0)
UITextRange
start
と end
が得られる
start
: 文章の先頭からn文字目
end
: 文章の先頭からm文字目(範囲選択していないなら start
と同様
といったデータが得られます。何行かをそのまま返してくれるAPI は無いので、変換処理を愚直に実装していきます。
func convertToSourceLocation (from selectedTextRange: UITextRange? , in textView: UITextView ) -> SourceLocation? {
guard let selectedTextRange else {
return nil
}
let location = textView.offset(from: textView.beginningOfDocument , to: selectedTextRange.start )
var lead : Int = 0
var trail : Int = 0
var currentLine : Int = 0
var column : Int = location
for line in textView.text.components(separatedBy: .newlines) {
trail = lead + line.count
if lead <= location && location <= trail {
let utf8BasedColumnCount = line.prefix (column).utf8.count
return SourceLocation(line: currentLine + 1 , column: utf8BasedColumnCount + 1 , source: nil )
}
currentLine += 1
column -= line.count + "\n" .count
lead = trail + 1
}
return nil
}
これでUITextRangeをSourceLocationに変換できました。
MarkupWalkerでカーソル位置が太字か確かめる
ようやく swift-markdown を使う段階に来ました。swift-markdown ではVisitor pattern を推奨しています。詳しい説明は省くのですが、公式ドキュメントのVisitors, Walkers, and Rewriters を読むとなんとなく雰囲気がつかめると思います。
さっそく、MarkupWalker
のprotocolを利用して実装をします。
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)
}
}
このStrongWalker
とSourceLocationの変換処理を利用すると、下記の実装ができます。
extension ViewController : UITextViewDelegate {
func textViewDidChangeSelection (_ textView: UITextView ) {
guard let cursorSourceLocation = convertToSourceLocation(from: textView.selectedTextRange , in : textView ) else {
return
}
let document = Document(parsing: textView.text )
var strongWalker = StrongWalker(cursorSourceLocation: cursorSourceLocation )
strongWalker.visit(document)
print(strongWalker.isStrong)
}
}
簡単なUIを作って様子を見る
ようやくロジック部分が完成したので、簡単なUIを作ってちゃんと動いているか見てみましょう。UILabel
をおいて
if strongWalker.isStrong {
label.text = "太字だよ"
} else {
label.text = "太字じゃない"
}
という簡単なコードを追加します。
カーソル位置が太字か判定している様子
無事に動いていそうでめでたいですね。
注意点
swift-markdown はMarkdown として壊れた文字列でも問題なく解析してくれます。しかし、その場合はSourceRange, SourceLocationが正常に見えるが間違っている値で返ってきます。
まとめ
swift-markdown を使うことで、Markdown を解析することができます。しかし、swift-markdown 内で使われるSourceLocationとSourceRangeは、UITextViewの世界とは異なる為に変換処理が必要かつ、1始まりのUTF-8 カウントという特殊な世界です。
また、もしあなたがガッツリとMarkdown エディタを実装し始めたい場合は、 SourceRange
<-> NSRange
<-> UITextRange
の変換迷宮に迷い込むでしょう。頑張って下さい。