diff --git a/Sources/Loci/DocumentViewerView.swift b/Sources/Loci/DocumentViewerView.swift index 88ffc20..71fdb90 100644 --- a/Sources/Loci/DocumentViewerView.swift +++ b/Sources/Loci/DocumentViewerView.swift @@ -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) @@ -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) } diff --git a/Sources/Loci/ExtendDocumentViewer.swift b/Sources/Loci/ExtendDocumentViewer.swift index 7af8286..8c79589 100644 --- a/Sources/Loci/ExtendDocumentViewer.swift +++ b/Sources/Loci/ExtendDocumentViewer.swift @@ -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 @@ -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) } } } @@ -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) @@ -202,6 +217,7 @@ struct ExtendDocumentViewer: View { .foregroundStyle(.white.opacity(0.55)) } .menuStyle(.borderlessButton) + .fixedSize() } } .padding(.horizontal, 14) @@ -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, @@ -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..") { + 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 diff --git a/Sources/Loci/LociApp.swift b/Sources/Loci/LociApp.swift index abdab99..346df35 100644 --- a/Sources/Loci/LociApp.swift +++ b/Sources/Loci/LociApp.swift @@ -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? @@ -74,6 +77,12 @@ 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 { @@ -81,18 +90,56 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, } 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 diff --git a/Sources/Loci/LociTelemetry.swift b/Sources/Loci/LociTelemetry.swift index 6acb926..90bee6f 100644 --- a/Sources/Loci/LociTelemetry.swift +++ b/Sources/Loci/LociTelemetry.swift @@ -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) } } diff --git a/Sources/Loci/ScrollFeel.swift b/Sources/Loci/ScrollFeel.swift index 998844a..eb4969e 100644 --- a/Sources/Loci/ScrollFeel.swift +++ b/Sources/Loci/ScrollFeel.swift @@ -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() @@ -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 } diff --git a/Sources/Loci/SettingsView.swift b/Sources/Loci/SettingsView.swift index f7a38e7..f46c658 100644 --- a/Sources/Loci/SettingsView.swift +++ b/Sources/Loci/SettingsView.swift @@ -44,7 +44,7 @@ struct SettingsView: View { @AppStorage("LociAutoCompile") private var autoCompile = false @AppStorage("LociVaultPath") private var vaultPath = "" @AppStorage("LociXRedirectMode") private var xRedirectModeRaw = XOAuthRedirectMode.recommended.rawValue - @AppStorage(LociTelemetry.enabledKey) private var telemetryEnabled = false + @AppStorage(LociTelemetry.enabledKey) private var telemetryEnabled = true @AppStorage(LociTelemetry.endpointKey) private var telemetryEndpoint = "" @StateObject private var xOAuth = XOAuthManager.shared diff --git a/Sources/Loci/StandaloneMarkdownViewer.swift b/Sources/Loci/StandaloneMarkdownViewer.swift new file mode 100644 index 0000000..7415a8c --- /dev/null +++ b/Sources/Loci/StandaloneMarkdownViewer.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI + +/// Viewer for markdown files opened from Finder ("Open With → Loci") or via +/// drag onto the app icon. The file is read in place from wherever it lives on +/// disk — nothing is copied into the library unless the user asks with the +/// toolbar's Add to Library action. +struct StandaloneMarkdownViewer: View { + let fileURL: URL + var store: LibraryStore? + + @State private var itemID = UUID() + @State private var pageIndex = 0 + @State private var addedToLibrary = false + + private var item: ReferenceItem { + ReferenceItem( + id: itemID, + title: fileURL.deletingPathExtension().lastPathComponent, + subtitle: fileURL.deletingLastPathComponent().lastPathComponent, + fileName: fileURL.lastPathComponent, + kind: .typography, + group: .file, + theme: .paper, + aspectRatio: 0.77, + collectionID: nil, + isInbox: false, + isTrashed: false, + canvasPosition: .zero, + infinityPosition: .zero + ) + } + + var body: some View { + ExtendDocumentViewer( + item: item, + originalURL: fileURL, + pageIndex: $pageIndex, + onAddToLibrary: addToLibraryAction + ) + .padding(10) + .frame(minWidth: 480, minHeight: 360) + .background(Color(red: 0.07, green: 0.07, blue: 0.08)) + } + + private var addToLibraryAction: (() -> Void)? { + guard let store, !addedToLibrary else { return nil } + return { + store.importFiles([fileURL]) + addedToLibrary = true + } + } +} diff --git a/Support/Loci.Info.plist b/Support/Loci.Info.plist index d7504e5..fc0f5da 100644 --- a/Support/Loci.Info.plist +++ b/Support/Loci.Info.plist @@ -18,6 +18,42 @@ Loci CFBundlePackageType APPL + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown Document + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + net.daringfireball.markdown + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + net.daringfireball.markdown + UTTypeDescription + Markdown Document + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + + md + markdown + + + + CFBundleURLTypes diff --git a/Tests/LociTests/TelemetryTests.swift b/Tests/LociTests/TelemetryTests.swift index c834c63..d9ff3e2 100644 --- a/Tests/LociTests/TelemetryTests.swift +++ b/Tests/LociTests/TelemetryTests.swift @@ -4,10 +4,15 @@ import Testing @Suite("Telemetry") struct TelemetryTests { - @Test("Telemetry is opt-in and allowlisted") - func telemetryOptInAndAllowlist() async throws { + @Test("Telemetry defaults on, honors opt-out, and allowlists properties") + func telemetryDefaultAndAllowlist() async throws { LociTelemetry.clearLocalQueue() LociTelemetry.endpointString = "" + + UserDefaults.standard.removeObject(forKey: LociTelemetry.enabledKey) + UserDefaults.standard.removeObject(forKey: "AtlasTelemetryEnabled") + #expect(LociTelemetry.isEnabled) + LociTelemetry.isEnabled = false LociTelemetry.record(.importCompleted, properties: [