Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/Loci/DocumentViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum DocumentViewerContent: Equatable {
case image(URL)
case quickLook(URL)
case plainText(String)
case markdown(String)
case websitePreview(thumbnailURL: URL?, websiteURL: URL?)
case preparing(String)
case unsupported(String)
Expand Down Expand Up @@ -85,6 +86,9 @@ enum DocumentViewerContentResolver {
!DocumentPreviewConverter.isOfficeDocument(originalURL),
let text = try? String(contentsOf: originalURL, encoding: .utf8),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if ext == "md" || ext == "markdown" {
return Result(content: .markdown(text), pdfPageCount: 0)
}
return Result(content: .plainText(text), pdfPageCount: 0)
}

Expand Down
195 changes: 195 additions & 0 deletions Sources/Loci/ExtendDocumentViewer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ struct ExtendDocumentViewer: View {
var item: ReferenceItem
var originalURL: URL?
@Binding var pageIndex: Int
/// Set when viewing a standalone file that is not in the library yet;
/// surfaces an "Add to Library" action in the toolbar.
var onAddToLibrary: (() -> Void)?

@State private var content: DocumentViewerContent = .unsupported("Loading…")
@State private var pageCount = 0
Expand Down Expand Up @@ -156,6 +159,7 @@ struct ExtendDocumentViewer: View {
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
}
.menuStyle(.borderlessButton)
.fixedSize()
viewerIconButton("plus") { adjustZoom(by: 10) }
}
}
Expand All @@ -178,6 +182,17 @@ struct ExtendDocumentViewer: View {
.background(Color.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
}

if let onAddToLibrary {
Button("Add to Library", action: onAddToLibrary)
.buttonStyle(.plain)
.lociFont(size: 10, weight: .semibold, relativeTo: .caption2)
.foregroundStyle(.white.opacity(0.82))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
.help("Import a copy of this file into the Loci library")
}

if let websiteURL = item.websiteURL, isWebsitePreview {
Button("Open in Browser") {
NSWorkspace.shared.open(websiteURL)
Expand All @@ -202,6 +217,7 @@ struct ExtendDocumentViewer: View {
.foregroundStyle(.white.opacity(0.55))
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
.padding(.horizontal, 14)
Expand Down Expand Up @@ -247,6 +263,8 @@ struct ExtendDocumentViewer: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .plainText(let text):
ExtendTextCanvas(text: text)
case .markdown(let text):
ExtendMarkdownCanvas(text: text)
case .websitePreview(let thumbnailURL, let websiteURL):
ExtendWebsitePreviewCanvas(
item: item,
Expand Down Expand Up @@ -651,6 +669,183 @@ private struct ExtendTextCanvas: View {
}
}

/// Renders markdown as formatted text on the dark viewer canvas. Block
/// structure (headings, lists, quotes, fences) is parsed by hand because
/// AttributedString's markdown init flattens it; inline emphasis, code, and
/// links go through AttributedString. Obsidian-style [[wikilinks]] display as
/// their titles — there is no vault context to resolve them against here.
private struct ExtendMarkdownCanvas: View {
private enum Block {
case heading(level: Int, text: AttributedString)
case paragraph(AttributedString)
case listItem(marker: String, text: AttributedString, indent: Int)
case quote(AttributedString)
case code(String)
case rule
}

private let blocks: [Block]

init(text: String) {
blocks = Self.parse(text)
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
blockView(block)
}
}
.frame(maxWidth: 720, alignment: .leading)
.frame(maxWidth: .infinity)
.padding(24)
}
.textSelection(.enabled)
.background(Color(red: 0.07, green: 0.07, blue: 0.08))
}

@ViewBuilder
private func blockView(_ block: Block) -> some View {
switch block {
case .heading(let level, let text):
Text(text)
.lociFont(
size: [20, 17, 14.5, 13, 12.5, 12][min(level, 6) - 1],
weight: .semibold,
relativeTo: .headline
)
.foregroundStyle(.white.opacity(0.94))
.padding(.top, level <= 2 ? 8 : 4)
case .paragraph(let text):
Text(text)
.lociFont(size: 12.5, relativeTo: .body)
.lineSpacing(3.5)
.foregroundStyle(.white.opacity(0.82))
case .listItem(let marker, let text, let indent):
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(marker)
.lociFont(size: 12, weight: .semibold, design: .rounded, relativeTo: .body)
.foregroundStyle(.white.opacity(0.45))
Text(text)
.lociFont(size: 12.5, relativeTo: .body)
.lineSpacing(3)
.foregroundStyle(.white.opacity(0.82))
}
.padding(.leading, CGFloat(indent) * 16)
case .quote(let text):
HStack(alignment: .top, spacing: 10) {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color.white.opacity(0.26))
.frame(width: 3)
Text(text)
.lociFont(size: 12.5, relativeTo: .body)
.italic()
.lineSpacing(3)
.foregroundStyle(.white.opacity(0.64))
}
.fixedSize(horizontal: false, vertical: true)
case .code(let code):
Text(code)
.lociFont(size: 11, design: .monospaced, relativeTo: .caption)
.foregroundStyle(.white.opacity(0.80))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
case .rule:
Rectangle()
.fill(Color.white.opacity(0.12))
.frame(height: 1)
.padding(.vertical, 4)
}
}

private static func parse(_ raw: String) -> [Block] {
var blocks: [Block] = []
var paragraph: [String] = []
var fenceLines: [String]?
var lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...]

// YAML frontmatter is metadata, not prose; show it as a code block.
if lines.first?.trimmingCharacters(in: .whitespaces) == "---",
let closing = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "---" }) {
blocks.append(.code(lines[lines.startIndex + 1..<closing].joined(separator: "\n")))
lines = lines[(closing + 1)...]
}

func flushParagraph() {
guard !paragraph.isEmpty else { return }
blocks.append(.paragraph(inline(paragraph.joined(separator: " "))))
paragraph.removeAll()
}

for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)

if fenceLines != nil {
if trimmed.hasPrefix("```") {
blocks.append(.code(fenceLines?.joined(separator: "\n") ?? ""))
fenceLines = nil
} else {
fenceLines?.append(line)
}
continue
}
if trimmed.hasPrefix("```") {
flushParagraph()
fenceLines = []
continue
}
if trimmed.isEmpty {
flushParagraph()
continue
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
flushParagraph()
blocks.append(.rule)
continue
}
let hashes = trimmed.prefix(while: { $0 == "#" }).count
if (1...6).contains(hashes), trimmed.dropFirst(hashes).first == " " {
flushParagraph()
let content = trimmed.dropFirst(hashes).trimmingCharacters(in: .whitespaces)
blocks.append(.heading(level: hashes, text: inline(content)))
continue
}
if trimmed.hasPrefix(">") {
flushParagraph()
let content = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
blocks.append(.quote(inline(content)))
continue
}
let indent = line.prefix(while: { $0 == " " || $0 == "\t" }).reduce(0) { $0 + ($1 == "\t" ? 2 : 1) } / 2
if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
flushParagraph()
blocks.append(.listItem(marker: "•", text: inline(String(trimmed.dropFirst(2))), indent: indent))
continue
}
if let match = trimmed.firstMatch(of: /^(\d{1,3})\.\s+(.*)$/) {
flushParagraph()
blocks.append(.listItem(marker: "\(match.1).", text: inline(String(match.2)), indent: indent))
continue
}
paragraph.append(trimmed)
}
if let fenceLines {
blocks.append(.code(fenceLines.joined(separator: "\n")))
}
flushParagraph()
return blocks
}

private static func inline(_ text: String) -> AttributedString {
var normalized = text.replacing(/\[\[([^\]|]+)\|([^\]]+)\]\]/) { String($0.output.2) }
normalized = normalized.replacing(/\[\[([^\]]+)\]\]/) { String($0.output.1) }
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
return (try? AttributedString(markdown: normalized, options: options)) ?? AttributedString(normalized)
}
}

private struct ExtendPreparingCanvas: View {
var message: String

Expand Down
61 changes: 54 additions & 7 deletions Sources/Loci/LociApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ extension Notification.Name {
final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSToolbarDelegate {
private var window: NSWindow?
private var settingsWindow: NSWindow?
private var markdownWindows: [NSWindow] = []
/// Files handed to us before the main window finished launching.
private var pendingMarkdownURLs: [URL] = []
private var localAPI: LocalReferenceAPIServer?
private var libraryStore: LibraryStore?

Expand Down Expand Up @@ -74,25 +77,69 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate,
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak libraryStore] in
libraryStore?.finishDeferredStartupWork()
}

let queued = pendingMarkdownURLs
pendingMarkdownURLs.removeAll()
for url in queued {
openStandaloneMarkdown(url)
}
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}

func application(_ application: NSApplication, open urls: [URL]) {
for url in urls where url.scheme == "loci" {
Task {
do {
try await XOAuthManager.shared.completeAuthorization(from: url)
openSettings()
} catch {
ErrorPresenter.shared.show(.networkError("X sign-in failed: \(error.localizedDescription)"))
for url in urls {
if url.scheme == "loci" {
Task {
do {
try await XOAuthManager.shared.completeAuthorization(from: url)
openSettings()
} catch {
ErrorPresenter.shared.show(.networkError("X sign-in failed: \(error.localizedDescription)"))
}
}
} else if url.isFileURL, ["md", "markdown"].contains(url.pathExtension.lowercased()) {
if window == nil {
pendingMarkdownURLs.append(url)
} else {
openStandaloneMarkdown(url)
}
}
}
}

/// Opens a markdown file in its own viewer window, reading it in place —
/// no library import happens unless the user asks from the viewer.
private func openStandaloneMarkdown(_ url: URL) {
let viewer = StandaloneMarkdownViewer(fileURL: url, store: libraryStore)
let hostingController = NSHostingController(rootView: viewer)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 780, height: 660),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = url.lastPathComponent
// The document viewer draws a dark canvas; give this window a dark
// titlebar (overriding the app-wide light pin) so the chrome matches.
window.appearance = NSAppearance(named: .darkAqua)
window.backgroundColor = NSColor(srgbRed: 0.07, green: 0.07, blue: 0.08, alpha: 1)
window.contentMinSize = NSSize(width: 480, height: 360)
window.contentViewController = hostingController
// NSHostingController shrinks the window to the SwiftUI ideal size
// (the frame minimums here); restore the intended reading size.
window.setContentSize(NSSize(width: 780, height: 660))
window.isReleasedWhenClosed = false
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)

markdownWindows.removeAll { !$0.isVisible && !$0.isMiniaturized }
markdownWindows.append(window)
}

private func setupMenus() {
let mainMenu = NSMenu()
let appName = AppBrand.name
Expand Down
7 changes: 6 additions & 1 deletion Sources/Loci/LociTelemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,17 @@ enum LociTelemetry {
"x_bookmark_count"
]

/// Enabled by default; users opt out from Settings → Privacy. An explicit
/// choice under either the current or legacy key always wins.
static var isEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: enabledKey) != nil {
return UserDefaults.standard.bool(forKey: enabledKey)
}
return UserDefaults.standard.bool(forKey: legacyEnabledKey)
if UserDefaults.standard.object(forKey: legacyEnabledKey) != nil {
return UserDefaults.standard.bool(forKey: legacyEnabledKey)
}
return true
}
set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
}
Expand Down
13 changes: 12 additions & 1 deletion Sources/Loci/ScrollFeel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct LociScrollFeel: NSViewRepresentable {
final class ScrollFeelProbeView: NSView {
var profile: LociScrollFeel.Profile = .library
private weak var configuredScrollView: NSScrollView?
private var retriesRemaining = 0

override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
Expand All @@ -40,14 +41,24 @@ final class ScrollFeelProbeView: NSView {
}

func scheduleConfiguration() {
retriesRemaining = 40
DispatchQueue.main.async { [weak self] in
self?.configureScrollView()
}
}

private func configureScrollView() {
// Detached probes get a fresh viewDidMoveToWindow when they return to
// a hierarchy; retrying while detached would spin the main queue at
// 100% CPU forever, so both the window guard and the retry budget are
// load-bearing.
guard window != nil else { return }
guard let scrollView = enclosingScrollView else {
scheduleConfiguration()
guard retriesRemaining > 0 else { return }
retriesRemaining -= 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.configureScrollView()
}
return
}

Expand Down
Loading