Add Table of Contents

This commit is contained in:
Shadowfacts 2020-12-20 13:45:22 -05:00
parent 1454e9dc01
commit 2b06a826ae
9 changed files with 349 additions and 3 deletions

View File

@ -81,6 +81,7 @@ class BrowserNavigationController: UIViewController {
])
toolbarView = ToolbarView(navigator: navigator)
toolbarView.showTableOfContents = self.showTableOfContents
toolbarView.showShareSheet = self.showShareSheet
toolbarView.showPreferences = self.showPreferences
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) {
let vc = UIActivityViewController(activityItems: [navigator.currentURL], applicationActivities: nil)
vc.popoverPresentationController?.sourceView = source

View File

@ -27,6 +27,7 @@ class BrowserWebViewController: UIViewController {
private var task: GeminiDataTask?
private let renderer = GeminiHTMLRenderer()
private(set) var document: Document?
private var loaded = false
private var errorStack: UIStackView!
@ -175,6 +176,8 @@ class BrowserWebViewController: UIViewController {
}
private func renderDocument(_ doc: Document) {
self.document = doc
let html = BrowserWebViewController.preamble + renderer.renderDocumentToHTML(doc) + BrowserWebViewController.postamble
DispatchQueue.main.async {
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 = """
<!doctype html>
<html>

View File

@ -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 }
}
}

View File

@ -13,6 +13,7 @@ class ToolbarView: UIView {
let navigator: NavigationManager
var showTableOfContents: (() -> Void)?
var showShareSheet: ((UIView) -> Void)?
var showPreferences: (() -> Void)?
@ -20,6 +21,7 @@ class ToolbarView: UIView {
private var backButton: UIButton!
private var forwardsButton: UIButton!
private var reloadButton: UIButton!
private var tableOfContentsButton: UIButton!
private var shareButton: UIButton!
private var prefsButton: UIButton!
@ -65,6 +67,12 @@ class ToolbarView: UIView {
reloadButton.accessibilityLabel = "Reload"
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.addTarget(self, action: #selector(sharePressed), for: .touchUpInside)
shareButton.setImage(UIImage(systemName: "square.and.arrow.up", withConfiguration: symbolConfig), for: .normal)
@ -81,6 +89,7 @@ class ToolbarView: UIView {
backButton,
forwardsButton,
reloadButton,
tableOfContentsButton,
shareButton,
prefsButton,
])
@ -114,6 +123,10 @@ class ToolbarView: UIView {
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
}
@objc private func tableOfContentsPressed() {
showTableOfContents?()
}
@objc private func sharePressed() {
showShareSheet?(shareButton)
}

View File

@ -56,6 +56,9 @@
D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AD24BEA29100E37622 /* GeminiResponse.swift */; };
D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AB2258E8E13008652BC /* ToolbarView.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 */; };
D6E1529824BFAAA400FDF9D3 /* BrowserWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -525,6 +531,7 @@
D62664AB24BBF26A00DF9B88 /* Info.plist */,
D62664C724BBF2C600DF9B88 /* Document.swift */,
D62664C524BBF27300DF9B88 /* GeminiParser.swift */,
D6BC9AC4258F01F6008652BC /* TableOfContents.swift */,
);
path = GeminiFormat;
sourceTree = "<group>";
@ -534,6 +541,7 @@
children = (
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */,
D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */,
D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */,
D62664B924BBF26A00DF9B88 /* Info.plist */,
);
path = GeminiFormatTests;
@ -591,6 +599,7 @@
D688F662258C2479003A0A73 /* UIViewController+Children.swift */,
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */,
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */,
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */,
D691A6762522382E00348C4B /* BrowserViewController.swift */,
D6E152A824BFFDF500FDF9D3 /* ContentView.swift */,
D691A68625223A4600348C4B /* NavigationBar.swift */,
@ -1071,6 +1080,7 @@
files = (
D62664C824BBF2C600DF9B88 /* Document.swift in Sources */,
D62664C624BBF27300DF9B88 /* GeminiParser.swift in Sources */,
D6BC9AC5258F01F6008652BC /* TableOfContents.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1079,6 +1089,7 @@
buildActionMask = 2147483647;
files = (
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */,
D6BC9ACE258F07BC008652BC /* TableOfContentsTests.swift in Sources */,
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1119,6 +1130,7 @@
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */,
D62BCEE2252553620031D894 /* ActivityView.swift in Sources */,
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */,
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */,
D691A68725223A4700348C4B /* NavigationBar.swift in Sources */,
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */,
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,

View File

@ -70,7 +70,7 @@ public extension Document {
}
public extension Document {
enum HeadingLevel: Int {
enum HeadingLevel: Int, Comparable {
case h1 = 1, h2 = 2, h3 = 3
var geminiText: String {
@ -83,5 +83,9 @@ public extension Document {
return "###"
}
}
public static func < (lhs: Document.HeadingLevel, rhs: Document.HeadingLevel) -> Bool {
return lhs.rawValue < rhs.rawValue
}
}
}

View File

@ -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
}
}
}

View File

@ -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))
}
}

View File

@ -22,7 +22,7 @@ public class GeminiHTMLRenderer {
var inPreformatting = false
var inList = false
for line in doc.lines {
for (index, line) in doc.lines.enumerated() {
if inList && !line.isListItem {
str += "</ul>"
}
@ -46,7 +46,7 @@ public class GeminiHTMLRenderer {
str += "\n"
case let .heading(text, level: level):
let tag = "h\(level.rawValue)"
str += "<\(tag)>\(text.htmlEscape())</\(tag)>"
str += "<\(tag) id=\"l\(index)\">\(text.htmlEscape())</\(tag)>"
case let .unorderedListItem(text):
if !inList {
inList = true