From 2b06a826ae7edd8dd8271b881d65f0fa061a0cae Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 20 Dec 2020 13:45:22 -0500 Subject: [PATCH] Add Table of Contents --- Gemini-iOS/BrowserNavigationController.swift | 14 ++ Gemini-iOS/BrowserWebViewController.swift | 20 +++ Gemini-iOS/TableOfContentsView.swift | 64 ++++++++ Gemini-iOS/ToolbarView.swift | 13 ++ Gemini.xcodeproj/project.pbxproj | 12 ++ GeminiFormat/Document.swift | 6 +- GeminiFormat/TableOfContents.swift | 71 +++++++++ GeminiFormatTests/TableOfContentsTests.swift | 148 +++++++++++++++++++ GeminiRenderer/GeminiHTMLRenderer.swift | 4 +- 9 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 Gemini-iOS/TableOfContentsView.swift create mode 100644 GeminiFormat/TableOfContents.swift create mode 100644 GeminiFormatTests/TableOfContentsTests.swift diff --git a/Gemini-iOS/BrowserNavigationController.swift b/Gemini-iOS/BrowserNavigationController.swift index b45e08a..2c9193a 100644 --- a/Gemini-iOS/BrowserNavigationController.swift +++ b/Gemini-iOS/BrowserNavigationController.swift @@ -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 diff --git a/Gemini-iOS/BrowserWebViewController.swift b/Gemini-iOS/BrowserWebViewController.swift index 7b27664..0f3d983 100644 --- a/Gemini-iOS/BrowserWebViewController.swift +++ b/Gemini-iOS/BrowserWebViewController.swift @@ -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 = """ diff --git a/Gemini-iOS/TableOfContentsView.swift b/Gemini-iOS/TableOfContentsView.swift new file mode 100644 index 0000000..1940438 --- /dev/null +++ b/Gemini-iOS/TableOfContentsView.swift @@ -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 } + } +} diff --git a/Gemini-iOS/ToolbarView.swift b/Gemini-iOS/ToolbarView.swift index fb7ff65..17d688b 100644 --- a/Gemini-iOS/ToolbarView.swift +++ b/Gemini-iOS/ToolbarView.swift @@ -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) } diff --git a/Gemini.xcodeproj/project.pbxproj b/Gemini.xcodeproj/project.pbxproj index f4fa80a..6b25a85 100644 --- a/Gemini.xcodeproj/project.pbxproj +++ b/Gemini.xcodeproj/project.pbxproj @@ -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 = ""; }; D6BC9AB2258E8E13008652BC /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; D6BC9ABB258E9862008652BC /* NavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarView.swift; sourceTree = ""; }; + D6BC9AC4258F01F6008652BC /* TableOfContents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContents.swift; sourceTree = ""; }; + D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.swift; sourceTree = ""; }; + D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsView.swift; sourceTree = ""; }; D6DA5782252396030048B65A /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowController.swift; sourceTree = ""; }; D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowserWindowController.xib; sourceTree = ""; }; @@ -525,6 +531,7 @@ D62664AB24BBF26A00DF9B88 /* Info.plist */, D62664C724BBF2C600DF9B88 /* Document.swift */, D62664C524BBF27300DF9B88 /* GeminiParser.swift */, + D6BC9AC4258F01F6008652BC /* TableOfContents.swift */, ); path = GeminiFormat; sourceTree = ""; @@ -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 */, diff --git a/GeminiFormat/Document.swift b/GeminiFormat/Document.swift index 8c6c0e8..6344ba7 100644 --- a/GeminiFormat/Document.swift +++ b/GeminiFormat/Document.swift @@ -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 + } } } diff --git a/GeminiFormat/TableOfContents.swift b/GeminiFormat/TableOfContents.swift new file mode 100644 index 0000000..de4a3cb --- /dev/null +++ b/GeminiFormat/TableOfContents.swift @@ -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 + } + } +} diff --git a/GeminiFormatTests/TableOfContentsTests.swift b/GeminiFormatTests/TableOfContentsTests.swift new file mode 100644 index 0000000..5f0f54b --- /dev/null +++ b/GeminiFormatTests/TableOfContentsTests.swift @@ -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)) + } + +} diff --git a/GeminiRenderer/GeminiHTMLRenderer.swift b/GeminiRenderer/GeminiHTMLRenderer.swift index df905ee..58a9136 100644 --- a/GeminiRenderer/GeminiHTMLRenderer.swift +++ b/GeminiRenderer/GeminiHTMLRenderer.swift @@ -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 += "" } @@ -46,7 +46,7 @@ public class GeminiHTMLRenderer { str += "\n" case let .heading(text, level: level): let tag = "h\(level.rawValue)" - str += "<\(tag)>\(text.htmlEscape())" + str += "<\(tag) id=\"l\(index)\">\(text.htmlEscape())" case let .unorderedListItem(text): if !inList { inList = true