Add Table of Contents
This commit is contained in:
parent
1454e9dc01
commit
2b06a826ae
|
@ -81,6 +81,7 @@ class BrowserNavigationController: UIViewController {
|
||||||
])
|
])
|
||||||
|
|
||||||
toolbarView = ToolbarView(navigator: navigator)
|
toolbarView = ToolbarView(navigator: navigator)
|
||||||
|
toolbarView.showTableOfContents = self.showTableOfContents
|
||||||
toolbarView.showShareSheet = self.showShareSheet
|
toolbarView.showShareSheet = self.showShareSheet
|
||||||
toolbarView.showPreferences = self.showPreferences
|
toolbarView.showPreferences = self.showPreferences
|
||||||
toolbarView.translatesAutoresizingMaskIntoConstraints = false
|
toolbarView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -265,6 +266,19 @@ class BrowserNavigationController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showTableOfContents() {
|
||||||
|
guard let doc = currentBrowserVC.document else { return }
|
||||||
|
let view = TableOfContentsView(document: doc) { (lineIndexToJumpTo) in
|
||||||
|
self.dismiss(animated: true) {
|
||||||
|
if let index = lineIndexToJumpTo {
|
||||||
|
self.currentBrowserVC.scrollToLine(index: index, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let host = UIHostingController(rootView: view)
|
||||||
|
present(host, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func showShareSheet(_ source: UIView) {
|
private func showShareSheet(_ source: UIView) {
|
||||||
let vc = UIActivityViewController(activityItems: [navigator.currentURL], applicationActivities: nil)
|
let vc = UIActivityViewController(activityItems: [navigator.currentURL], applicationActivities: nil)
|
||||||
vc.popoverPresentationController?.sourceView = source
|
vc.popoverPresentationController?.sourceView = source
|
||||||
|
|
|
@ -27,6 +27,7 @@ class BrowserWebViewController: UIViewController {
|
||||||
|
|
||||||
private var task: GeminiDataTask?
|
private var task: GeminiDataTask?
|
||||||
private let renderer = GeminiHTMLRenderer()
|
private let renderer = GeminiHTMLRenderer()
|
||||||
|
private(set) var document: Document?
|
||||||
private var loaded = false
|
private var loaded = false
|
||||||
|
|
||||||
private var errorStack: UIStackView!
|
private var errorStack: UIStackView!
|
||||||
|
@ -175,6 +176,8 @@ class BrowserWebViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renderDocument(_ doc: Document) {
|
private func renderDocument(_ doc: Document) {
|
||||||
|
self.document = doc
|
||||||
|
|
||||||
let html = BrowserWebViewController.preamble + renderer.renderDocumentToHTML(doc) + BrowserWebViewController.postamble
|
let html = BrowserWebViewController.preamble + renderer.renderDocumentToHTML(doc) + BrowserWebViewController.postamble
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.webView.isHidden = false
|
self.webView.isHidden = false
|
||||||
|
@ -190,6 +193,23 @@ class BrowserWebViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scrollToLine(index: Int, animated: Bool) {
|
||||||
|
if animated {
|
||||||
|
webView.evaluateJavaScript("document.getElementById('l\(index)').getBoundingClientRect().top + window.scrollY") { (result, error) in
|
||||||
|
guard let result = result as? CGFloat else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scrollView = self.webView.scrollView
|
||||||
|
let y = result * scrollView.zoomScale - scrollView.safeAreaInsets.top
|
||||||
|
let maxY = scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom
|
||||||
|
let finalOffsetY = min(y, maxY)
|
||||||
|
self.webView.scrollView.setContentOffset(CGPoint(x: 0, y: finalOffsetY), animated: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
webView.evaluateJavaScript("document.getElementById('l\(index)').scrollIntoView();")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static let preamble = """
|
private static let preamble = """
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
//
|
||||||
|
// TableOfContentsView.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/20/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import GeminiFormat
|
||||||
|
|
||||||
|
struct TableOfContentsView: View {
|
||||||
|
private let entries: [Entry]
|
||||||
|
private let close: (Int?) -> Void
|
||||||
|
|
||||||
|
init(document: Document, close: @escaping (Int?) -> Void) {
|
||||||
|
let toc = TableOfContents(document: document)
|
||||||
|
self.entries = toc.entries.flatMap { TableOfContentsView.flattenToCEntry($0) }
|
||||||
|
self.close = close
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func flattenToCEntry(_ e: TableOfContents.Entry, depth: Int = 0) -> [Entry] {
|
||||||
|
guard case let .heading(text, level: _) = e.line else { fatalError() }
|
||||||
|
var entries = e.children.flatMap {
|
||||||
|
flattenToCEntry($0, depth: depth + 1)
|
||||||
|
}
|
||||||
|
entries.insert(Entry(title: text, lineIndex: e.lineIndex, depth: depth), at: 0)
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List(Array(entries.enumerated()), id: \.0) { (a) in
|
||||||
|
Button {
|
||||||
|
close(a.1.lineIndex)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
.frame(width: CGFloat(a.1.depth * 25))
|
||||||
|
Text(verbatim: a.1.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(PlainListStyle())
|
||||||
|
.navigationBarTitle("Table of Contents", displayMode: .inline)
|
||||||
|
.navigationBarItems(trailing: Button("Done", action: {
|
||||||
|
close(nil)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TableOfContentsView {
|
||||||
|
struct Entry {
|
||||||
|
let title: String
|
||||||
|
let lineIndex: Int
|
||||||
|
let depth: Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TableOfContentsView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
TableOfContentsView(document: Document(url: URL(string: "gemini://example.com")!, lines: [])) { (_) in }
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ class ToolbarView: UIView {
|
||||||
|
|
||||||
let navigator: NavigationManager
|
let navigator: NavigationManager
|
||||||
|
|
||||||
|
var showTableOfContents: (() -> Void)?
|
||||||
var showShareSheet: ((UIView) -> Void)?
|
var showShareSheet: ((UIView) -> Void)?
|
||||||
var showPreferences: (() -> Void)?
|
var showPreferences: (() -> Void)?
|
||||||
|
|
||||||
|
@ -20,6 +21,7 @@ class ToolbarView: UIView {
|
||||||
private var backButton: UIButton!
|
private var backButton: UIButton!
|
||||||
private var forwardsButton: UIButton!
|
private var forwardsButton: UIButton!
|
||||||
private var reloadButton: UIButton!
|
private var reloadButton: UIButton!
|
||||||
|
private var tableOfContentsButton: UIButton!
|
||||||
private var shareButton: UIButton!
|
private var shareButton: UIButton!
|
||||||
private var prefsButton: UIButton!
|
private var prefsButton: UIButton!
|
||||||
|
|
||||||
|
@ -65,6 +67,12 @@ class ToolbarView: UIView {
|
||||||
reloadButton.accessibilityLabel = "Reload"
|
reloadButton.accessibilityLabel = "Reload"
|
||||||
reloadButton.isPointerInteractionEnabled = true
|
reloadButton.isPointerInteractionEnabled = true
|
||||||
|
|
||||||
|
tableOfContentsButton = UIButton()
|
||||||
|
tableOfContentsButton.addTarget(self, action: #selector(tableOfContentsPressed), for: .touchUpInside)
|
||||||
|
tableOfContentsButton.setImage(UIImage(systemName: "list.bullet.indent", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
tableOfContentsButton.accessibilityLabel = "Table of Contents"
|
||||||
|
tableOfContentsButton.isPointerInteractionEnabled = true
|
||||||
|
|
||||||
shareButton = UIButton()
|
shareButton = UIButton()
|
||||||
shareButton.addTarget(self, action: #selector(sharePressed), for: .touchUpInside)
|
shareButton.addTarget(self, action: #selector(sharePressed), for: .touchUpInside)
|
||||||
shareButton.setImage(UIImage(systemName: "square.and.arrow.up", withConfiguration: symbolConfig), for: .normal)
|
shareButton.setImage(UIImage(systemName: "square.and.arrow.up", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
@ -81,6 +89,7 @@ class ToolbarView: UIView {
|
||||||
backButton,
|
backButton,
|
||||||
forwardsButton,
|
forwardsButton,
|
||||||
reloadButton,
|
reloadButton,
|
||||||
|
tableOfContentsButton,
|
||||||
shareButton,
|
shareButton,
|
||||||
prefsButton,
|
prefsButton,
|
||||||
])
|
])
|
||||||
|
@ -114,6 +123,10 @@ class ToolbarView: UIView {
|
||||||
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
|
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func tableOfContentsPressed() {
|
||||||
|
showTableOfContents?()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func sharePressed() {
|
@objc private func sharePressed() {
|
||||||
showShareSheet?(shareButton)
|
showShareSheet?(shareButton)
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,9 @@
|
||||||
D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AD24BEA29100E37622 /* GeminiResponse.swift */; };
|
D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AD24BEA29100E37622 /* GeminiResponse.swift */; };
|
||||||
D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AB2258E8E13008652BC /* ToolbarView.swift */; };
|
D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AB2258E8E13008652BC /* ToolbarView.swift */; };
|
||||||
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9ABB258E9862008652BC /* NavigationBarView.swift */; };
|
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9ABB258E9862008652BC /* NavigationBarView.swift */; };
|
||||||
|
D6BC9AC5258F01F6008652BC /* TableOfContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AC4258F01F6008652BC /* TableOfContents.swift */; };
|
||||||
|
D6BC9ACE258F07BC008652BC /* TableOfContentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */; };
|
||||||
|
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */; };
|
||||||
D6DA5783252396030048B65A /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DA5782252396030048B65A /* View+Extensions.swift */; };
|
D6DA5783252396030048B65A /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DA5782252396030048B65A /* View+Extensions.swift */; };
|
||||||
D6E1529824BFAAA400FDF9D3 /* BrowserWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */; };
|
D6E1529824BFAAA400FDF9D3 /* BrowserWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */; };
|
||||||
D6E1529924BFAAA400FDF9D3 /* BrowserWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */; };
|
D6E1529924BFAAA400FDF9D3 /* BrowserWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */; };
|
||||||
|
@ -325,6 +328,9 @@
|
||||||
D69F00AF24BEA84D00E37622 /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = "<group>"; };
|
D69F00AF24BEA84D00E37622 /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = "<group>"; };
|
||||||
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = "<group>"; };
|
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = "<group>"; };
|
||||||
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarView.swift; sourceTree = "<group>"; };
|
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarView.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9AC4258F01F6008652BC /* TableOfContents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContents.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsView.swift; sourceTree = "<group>"; };
|
||||||
D6DA5782252396030048B65A /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
|
D6DA5782252396030048B65A /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowController.swift; sourceTree = "<group>"; };
|
D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowController.swift; sourceTree = "<group>"; };
|
||||||
D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowserWindowController.xib; sourceTree = "<group>"; };
|
D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowserWindowController.xib; sourceTree = "<group>"; };
|
||||||
|
@ -525,6 +531,7 @@
|
||||||
D62664AB24BBF26A00DF9B88 /* Info.plist */,
|
D62664AB24BBF26A00DF9B88 /* Info.plist */,
|
||||||
D62664C724BBF2C600DF9B88 /* Document.swift */,
|
D62664C724BBF2C600DF9B88 /* Document.swift */,
|
||||||
D62664C524BBF27300DF9B88 /* GeminiParser.swift */,
|
D62664C524BBF27300DF9B88 /* GeminiParser.swift */,
|
||||||
|
D6BC9AC4258F01F6008652BC /* TableOfContents.swift */,
|
||||||
);
|
);
|
||||||
path = GeminiFormat;
|
path = GeminiFormat;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -534,6 +541,7 @@
|
||||||
children = (
|
children = (
|
||||||
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */,
|
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */,
|
||||||
D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */,
|
D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */,
|
||||||
|
D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */,
|
||||||
D62664B924BBF26A00DF9B88 /* Info.plist */,
|
D62664B924BBF26A00DF9B88 /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = GeminiFormatTests;
|
path = GeminiFormatTests;
|
||||||
|
@ -591,6 +599,7 @@
|
||||||
D688F662258C2479003A0A73 /* UIViewController+Children.swift */,
|
D688F662258C2479003A0A73 /* UIViewController+Children.swift */,
|
||||||
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */,
|
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */,
|
||||||
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */,
|
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */,
|
||||||
|
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */,
|
||||||
D691A6762522382E00348C4B /* BrowserViewController.swift */,
|
D691A6762522382E00348C4B /* BrowserViewController.swift */,
|
||||||
D6E152A824BFFDF500FDF9D3 /* ContentView.swift */,
|
D6E152A824BFFDF500FDF9D3 /* ContentView.swift */,
|
||||||
D691A68625223A4600348C4B /* NavigationBar.swift */,
|
D691A68625223A4600348C4B /* NavigationBar.swift */,
|
||||||
|
@ -1071,6 +1080,7 @@
|
||||||
files = (
|
files = (
|
||||||
D62664C824BBF2C600DF9B88 /* Document.swift in Sources */,
|
D62664C824BBF2C600DF9B88 /* Document.swift in Sources */,
|
||||||
D62664C624BBF27300DF9B88 /* GeminiParser.swift in Sources */,
|
D62664C624BBF27300DF9B88 /* GeminiParser.swift in Sources */,
|
||||||
|
D6BC9AC5258F01F6008652BC /* TableOfContents.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -1079,6 +1089,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */,
|
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */,
|
||||||
|
D6BC9ACE258F07BC008652BC /* TableOfContentsTests.swift in Sources */,
|
||||||
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */,
|
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -1119,6 +1130,7 @@
|
||||||
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */,
|
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */,
|
||||||
D62BCEE2252553620031D894 /* ActivityView.swift in Sources */,
|
D62BCEE2252553620031D894 /* ActivityView.swift in Sources */,
|
||||||
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */,
|
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */,
|
||||||
|
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */,
|
||||||
D691A68725223A4700348C4B /* NavigationBar.swift in Sources */,
|
D691A68725223A4700348C4B /* NavigationBar.swift in Sources */,
|
||||||
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */,
|
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */,
|
||||||
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,
|
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,
|
||||||
|
|
|
@ -70,7 +70,7 @@ public extension Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Document {
|
public extension Document {
|
||||||
enum HeadingLevel: Int {
|
enum HeadingLevel: Int, Comparable {
|
||||||
case h1 = 1, h2 = 2, h3 = 3
|
case h1 = 1, h2 = 2, h3 = 3
|
||||||
|
|
||||||
var geminiText: String {
|
var geminiText: String {
|
||||||
|
@ -83,5 +83,9 @@ public extension Document {
|
||||||
return "###"
|
return "###"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func < (lhs: Document.HeadingLevel, rhs: Document.HeadingLevel) -> Bool {
|
||||||
|
return lhs.rawValue < rhs.rawValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// TableOfContents.swift
|
||||||
|
// GeminiFormat
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/19/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct TableOfContents {
|
||||||
|
public let entries: [Entry]
|
||||||
|
|
||||||
|
public init(document: Document) {
|
||||||
|
self.entries = TableOfContents.entries(lines: document.lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func entries(lines: [Document.Line]) -> [Entry] {
|
||||||
|
var topLevelEntries = [Entry]()
|
||||||
|
|
||||||
|
var currentEntries = [Entry]()
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while index < lines.count {
|
||||||
|
defer { index += 1 }
|
||||||
|
|
||||||
|
let line = lines[index]
|
||||||
|
guard case let .heading(_, level: level) = line else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let newEntry = Entry(line: line, lineIndex: index)
|
||||||
|
|
||||||
|
while !currentEntries.isEmpty && level <= currentEntries.last!.level {
|
||||||
|
currentEntries.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let last = currentEntries.last {
|
||||||
|
last.children.append(newEntry)
|
||||||
|
currentEntries.append(newEntry)
|
||||||
|
} else {
|
||||||
|
topLevelEntries.append(newEntry)
|
||||||
|
currentEntries.append(newEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return topLevelEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension TableOfContents {
|
||||||
|
class Entry: Equatable {
|
||||||
|
public let line: Document.Line
|
||||||
|
let level: Document.HeadingLevel
|
||||||
|
public let lineIndex: Int
|
||||||
|
public fileprivate(set) var children: [Entry]
|
||||||
|
|
||||||
|
init(line: Document.Line, lineIndex: Int) {
|
||||||
|
guard case let .heading(_, level: level) = line else { fatalError() }
|
||||||
|
self.line = line
|
||||||
|
self.level = level
|
||||||
|
self.lineIndex = lineIndex
|
||||||
|
self.children = []
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: Entry, rhs: Entry) -> Bool {
|
||||||
|
return lhs.line == rhs.line && lhs.lineIndex == rhs.lineIndex && lhs.children == rhs.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
//
|
||||||
|
// TableOfContentsTests.swift
|
||||||
|
// GeminiFormatTests
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/19/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import GeminiFormat
|
||||||
|
|
||||||
|
class TableOfContentsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testOneHeading() {
|
||||||
|
let one = Document(url: URL(string: "gemini://example.com/")!, lines: [
|
||||||
|
.heading("Heading", level: .h1)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: one).entries, [
|
||||||
|
.init(line: .heading("Heading", level: .h1), lineIndex: 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
let two = Document(url: URL(string: "gemini://example.com/")!, lines: [
|
||||||
|
.heading("Heading", level: .h2)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: two).entries, [
|
||||||
|
.init(line: .heading("Heading", level: .h2), lineIndex: 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
let three = Document(url: URL(string: "gemini://example.com/")!, lines: [
|
||||||
|
.heading("Heading", level: .h3)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: three).entries, [
|
||||||
|
.init(line: .heading("Heading", level: .h3), lineIndex: 0)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleTopLevelHeadings() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("One", level: .h1),
|
||||||
|
.heading("Two", level: .h1),
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: doc).entries, [
|
||||||
|
.init(line: .heading("One", level: .h1), lineIndex: 0),
|
||||||
|
.init(line: .heading("Two", level: .h1), lineIndex: 1)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNestedHeadings() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("One", level: .h1),
|
||||||
|
.heading("Two", level: .h2),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 1)
|
||||||
|
XCTAssertEqual(entries[0].line, .heading("One", level: .h1))
|
||||||
|
XCTAssertEqual(entries[0].lineIndex, 0)
|
||||||
|
XCTAssertEqual(entries[0].children, [
|
||||||
|
.init(line: .heading("Two", level: .h2), lineIndex: 1)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTriplyNestedHeadings() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("One", level: .h1),
|
||||||
|
.heading("Two", level: .h2),
|
||||||
|
.heading("Three", level: .h3),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 1)
|
||||||
|
XCTAssertEqual(entries[0].line, .heading("One", level: .h1))
|
||||||
|
XCTAssertEqual(entries[0].lineIndex, 0)
|
||||||
|
XCTAssertEqual(entries[0].children.count, 1)
|
||||||
|
XCTAssertEqual(entries[0].children[0].line, .heading("Two", level: .h2))
|
||||||
|
XCTAssertEqual(entries[0].children[0].lineIndex, 1)
|
||||||
|
XCTAssertEqual(entries[0].children[0].children, [
|
||||||
|
.init(line: .heading("Three", level: .h3), lineIndex: 2)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleTopLevelSections() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("Top Level One", level: .h1),
|
||||||
|
.heading("A", level: .h2),
|
||||||
|
.heading("Top Level Two", level: .h1),
|
||||||
|
.heading("B", level: .h2),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 2)
|
||||||
|
let first = entries[0]
|
||||||
|
XCTAssertEqual(first.line, .heading("Top Level One", level: .h1))
|
||||||
|
XCTAssertEqual(first.lineIndex, 0)
|
||||||
|
XCTAssertEqual(first.children, [
|
||||||
|
.init(line: .heading("A", level: .h2), lineIndex: 1)
|
||||||
|
])
|
||||||
|
let second = entries[1]
|
||||||
|
XCTAssertEqual(second.line, .heading("Top Level Two", level: .h1))
|
||||||
|
XCTAssertEqual(second.lineIndex, 2)
|
||||||
|
XCTAssertEqual(second.children, [
|
||||||
|
.init(line: .heading("B", level: .h2), lineIndex: 3)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleNestedSections() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("Top Level", level: .h1),
|
||||||
|
.heading("A", level: .h2),
|
||||||
|
.heading("Third Level", level: .h3),
|
||||||
|
.heading("B", level: .h2),
|
||||||
|
.heading("Third Level 2", level: .h3),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 1)
|
||||||
|
let topLevel = entries[0]
|
||||||
|
XCTAssertEqual(topLevel.line, .heading("Top Level", level: .h1))
|
||||||
|
XCTAssertEqual(topLevel.lineIndex, 0)
|
||||||
|
let children = topLevel.children
|
||||||
|
XCTAssertEqual(children.count, 2)
|
||||||
|
let first = children[0]
|
||||||
|
XCTAssertEqual(first.line, .heading("A", level: .h2))
|
||||||
|
XCTAssertEqual(first.lineIndex, 1)
|
||||||
|
XCTAssertEqual(first.children, [
|
||||||
|
.init(line: .heading("Third Level", level: .h3), lineIndex: 2)
|
||||||
|
])
|
||||||
|
let second = children[1]
|
||||||
|
XCTAssertEqual(second.line, .heading("B", level: .h2))
|
||||||
|
XCTAssertEqual(second.lineIndex, 3)
|
||||||
|
XCTAssertEqual(second.children, [
|
||||||
|
.init(line: .heading("Third Level 2", level: .h3), lineIndex: 4)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonH1TopLevelSections() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("A", level: .h2),
|
||||||
|
.heading("B", level: .h3),
|
||||||
|
.heading("C", level: .h1),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 2)
|
||||||
|
let first = entries[0]
|
||||||
|
XCTAssertEqual(first.line, .heading("A", level: .h2))
|
||||||
|
XCTAssertEqual(first.lineIndex, 0)
|
||||||
|
XCTAssertEqual(first.children, [
|
||||||
|
.init(line: .heading("B", level: .h3), lineIndex: 1)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(entries[1], .init(line: .heading("C", level: .h1), lineIndex: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -22,7 +22,7 @@ public class GeminiHTMLRenderer {
|
||||||
var inPreformatting = false
|
var inPreformatting = false
|
||||||
var inList = false
|
var inList = false
|
||||||
|
|
||||||
for line in doc.lines {
|
for (index, line) in doc.lines.enumerated() {
|
||||||
if inList && !line.isListItem {
|
if inList && !line.isListItem {
|
||||||
str += "</ul>"
|
str += "</ul>"
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ public class GeminiHTMLRenderer {
|
||||||
str += "\n"
|
str += "\n"
|
||||||
case let .heading(text, level: level):
|
case let .heading(text, level: level):
|
||||||
let tag = "h\(level.rawValue)"
|
let tag = "h\(level.rawValue)"
|
||||||
str += "<\(tag)>\(text.htmlEscape())</\(tag)>"
|
str += "<\(tag) id=\"l\(index)\">\(text.htmlEscape())</\(tag)>"
|
||||||
case let .unorderedListItem(text):
|
case let .unorderedListItem(text):
|
||||||
if !inList {
|
if !inList {
|
||||||
inList = true
|
inList = true
|
||||||
|
|
Loading…
Reference in New Issue