From 6b3b2db01302d378b558848e33f2028f49995577 Mon Sep 17 00:00:00 2001 From: Sven Date: Sat, 21 Mar 2026 18:43:46 +0100 Subject: [PATCH] Toolbar, Editor Window --- bookstax/Extensions/String+HTML.swift | 1 + bookstax/MockData.swift | 1 - bookstax/Models/DTOs.swift | 10 +- bookstax/Services/BookStackAPI.swift | 36 ++-- bookstax/Views/Editor/PageEditorView.swift | 122 ++++++++++--- bookstax/Views/Library/BookDetailView.swift | 2 +- bookstax/Views/Library/LibraryView.swift | 6 +- bookstax/Views/Reader/PageReaderView.swift | 183 +++++++++++++------- 8 files changed, 258 insertions(+), 103 deletions(-) diff --git a/bookstax/Extensions/String+HTML.swift b/bookstax/Extensions/String+HTML.swift index 24ae774..0976f32 100644 --- a/bookstax/Extensions/String+HTML.swift +++ b/bookstax/Extensions/String+HTML.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit extension String { /// Strips HTML tags and decodes common HTML entities for plain-text display. diff --git a/bookstax/MockData.swift b/bookstax/MockData.swift index 27ebc2c..33b7084 100644 --- a/bookstax/MockData.swift +++ b/bookstax/MockData.swift @@ -96,7 +96,6 @@ extension SearchResultDTO { extension CommentDTO { static let mock = CommentDTO( id: 1, - text: "Great documentation! Very helpful.", html: "

Great documentation! Very helpful.

", pageId: 1, createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil), diff --git a/bookstax/Models/DTOs.swift b/bookstax/Models/DTOs.swift index 5d33c60..0bad087 100644 --- a/bookstax/Models/DTOs.swift +++ b/bookstax/Models/DTOs.swift @@ -207,7 +207,6 @@ nonisolated struct TagListResponseDTO: Codable, Sendable { nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable { let id: Int - let text: String let html: String let pageId: Int let createdBy: UserSummaryDTO @@ -215,14 +214,19 @@ nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable { let updatedAt: Date enum CodingKeys: String, CodingKey { - case id, text, html - case pageId = "entity_id" + case id, html + case pageId = "commentable_id" case createdBy = "created_by" case createdAt = "created_at" case updatedAt = "updated_at" } } +/// Minimal shape returned by the list endpoint — only used to get IDs for detail fetches. +nonisolated struct CommentSummaryDTO: Codable, Sendable { + let id: Int +} + nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable { let id: Int let name: String diff --git a/bookstax/Services/BookStackAPI.swift b/bookstax/Services/BookStackAPI.swift index f1fa623..2ef2ecf 100644 --- a/bookstax/Services/BookStackAPI.swift +++ b/bookstax/Services/BookStackAPI.swift @@ -317,25 +317,37 @@ actor BookStackAPI { // MARK: - Comments func fetchComments(pageId: Int) async throws -> [CommentDTO] { - let response: PaginatedResponse = try await request( - endpoint: "comments?entity_type=page&entity_id=\(pageId)" + let list: PaginatedResponse = try await request( + endpoint: "comments?filter[commentable_type]=page&filter[commentable_id]=\(pageId)&count=100" ) - return response.data + // Fetch full detail for each comment in parallel to get html + user info + return try await withThrowingTaskGroup(of: CommentDTO.self) { group in + for summary in list.data { + group.addTask { + try await self.request(endpoint: "comments/\(summary.id)") + } + } + var results: [CommentDTO] = [] + for try await comment in group { results.append(comment) } + return results.sorted { $0.createdAt < $1.createdAt } + } } - func postComment(pageId: Int, text: String) async throws -> CommentDTO { + func postComment(pageId: Int, text: String) async throws { struct Body: Encodable, Sendable { - let text: String - let entityId: Int - let entityType: String + let pageId: Int + let html: String enum CodingKeys: String, CodingKey { - case text - case entityId = "entity_id" - case entityType = "entity_type" + case pageId = "page_id" + case html } } - return try await request(endpoint: "comments", method: "POST", - body: Body(text: text, entityId: pageId, entityType: "page")) + // The POST response shape differs from CommentDTO — use EmptyResponse and discard it + struct PostCommentResponse: Decodable, Sendable { let id: Int } + // BookStack expects HTML content — wrap plain text in a paragraph tag + let html = "

\(text.replacingOccurrences(of: "\n", with: "
"))

" + let _: PostCommentResponse = try await request(endpoint: "comments", method: "POST", + body: Body(pageId: pageId, html: html)) } func deleteComment(id: Int) async throws { diff --git a/bookstax/Views/Editor/PageEditorView.swift b/bookstax/Views/Editor/PageEditorView.swift index 2f55ee9..a598add 100644 --- a/bookstax/Views/Editor/PageEditorView.swift +++ b/bookstax/Views/Editor/PageEditorView.swift @@ -3,28 +3,59 @@ import UIKit import WebKit import PhotosUI +// MARK: - UITextView subclass that intercepts image paste from the context menu + +final class ImagePasteTextView: UITextView { + /// Called when the user selects Paste and the clipboard contains an image. + var onImagePaste: ((UIImage) -> Void)? + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + // Show the standard Paste item whenever the clipboard has an image OR text. + if action == #selector(paste(_:)) { + return UIPasteboard.general.hasImages || UIPasteboard.general.hasStrings + } + return super.canPerformAction(action, withSender: sender) + } + + override func paste(_ sender: Any?) { + // If there is an image on the clipboard, intercept and forward it. + if let image = UIPasteboard.general.image { + onImagePaste?(image) + } else { + super.paste(sender) + } + } +} + // MARK: - UITextView wrapper that exposes selection-aware formatting struct MarkdownTextEditor: UIViewRepresentable { @Binding var text: String /// Called with the UITextView so the parent can apply formatting var onTextViewReady: (UITextView) -> Void + /// Called when the user pastes an image via the context menu + var onImagePaste: ((UIImage) -> Void)? = nil - func makeUIView(context: Context) -> UITextView { - let tv = UITextView() + func makeUIView(context: Context) -> ImagePasteTextView { + let tv = ImagePasteTextView() tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular) tv.autocorrectionType = .no tv.autocapitalizationType = .none tv.delegate = context.coordinator tv.backgroundColor = .clear - tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + tv.isScrollEnabled = true + tv.alwaysBounceVertical = true + tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 80, right: 8) // Set initial text (e.g. when editing an existing page) tv.text = text + tv.onImagePaste = onImagePaste onTextViewReady(tv) return tv } - func updateUIView(_ tv: UITextView, context: Context) { + func updateUIView(_ tv: ImagePasteTextView, context: Context) { + // Keep the paste closure up to date (captures may change across renders) + tv.onImagePaste = onImagePaste // Only push changes that originated outside the UITextView (e.g. formatting toolbar). // Skip updates triggered by the user typing to avoid cursor position resets. guard !context.coordinator.isEditing, tv.text != text else { return } @@ -147,12 +178,15 @@ struct PageEditorView: View { ) Rectangle().fill(Color(.separator)).frame(height: 0.5) - // Content area - if viewModel.activeTab == .write { - writeArea - } else { - MarkdownPreviewView(markdown: viewModel.markdownContent) + // Content area — fills all remaining vertical space + Group { + if viewModel.activeTab == .write { + writeArea + } else { + MarkdownPreviewView(markdown: viewModel.markdownContent) + } } + .frame(maxHeight: .infinity) // Save error if let error = viewModel.saveError { @@ -160,12 +194,22 @@ struct PageEditorView: View { .padding() } } + .frame(maxHeight: .infinity) } @ViewBuilder private var writeArea: some View { VStack(spacing: 0) { - MarkdownTextEditor(text: $viewModel.markdownContent) { tv in textView = tv } + MarkdownTextEditor(text: $viewModel.markdownContent, + onTextViewReady: { tv in textView = tv }, + onImagePaste: { image in + Task { + let data = image.jpegData(compressionQuality: 0.85) ?? Data() + guard !data.isEmpty else { return } + await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg") + } + }) + .frame(maxHeight: .infinity) if case .uploading = viewModel.imageUploadState { HStack(spacing: 8) { @@ -197,7 +241,9 @@ struct PageEditorView: View { isUploadingImage: { if case .uploading = viewModel.imageUploadState { return true } return false - }() + }(), + clipboardHasImage: UIPasteboard.general.hasImages, + onPasteImage: { Task { await pasteImageFromClipboard() } } ) { action in applyFormat(action) } } } @@ -245,6 +291,15 @@ struct PageEditorView: View { } } + // MARK: - Paste image from clipboard + + private func pasteImageFromClipboard() async { + guard let uiImage = UIPasteboard.general.image else { return } + let data = uiImage.jpegData(compressionQuality: 0.85) ?? Data() + guard !data.isEmpty else { return } + await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg") + } + // MARK: - Apply formatting to selected text (or insert at cursor) private func applyFormat(_ action: FormatAction) { @@ -421,6 +476,8 @@ enum FormatAction { struct FormattingToolbar: View { @Binding var imagePickerItem: PhotosPickerItem? let isUploadingImage: Bool + let clipboardHasImage: Bool + let onPasteImage: () -> Void let onAction: (FormatAction) -> Void var body: some View { @@ -429,7 +486,7 @@ struct FormattingToolbar: View { .fill(Color(.separator)) .frame(height: 0.5) - // Row 1: Headings + text formatting + // Row 1: Headings + inline formatting HStack(spacing: 0) { FormatButton("H1", action: .h1, onAction: onAction) FormatButton("H2", action: .h2, onAction: onAction) @@ -440,7 +497,7 @@ struct FormattingToolbar: View { FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction) FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: 28) Rectangle() .fill(Color(.separator)) @@ -456,7 +513,7 @@ struct FormattingToolbar: View { FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction) FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction) toolbarDivider - // Image picker + // Image picker (from photo library) PhotosPicker( selection: $imagePickerItem, matching: .images, @@ -467,15 +524,23 @@ struct FormattingToolbar: View { ProgressView().controlSize(.small) } else { Image(systemName: "photo") - .font(.system(size: 14, weight: .regular)) + .font(.system(size: 13, weight: .regular)) } } - .frame(maxWidth: .infinity, minHeight: 36) + .frame(maxWidth: .infinity, minHeight: 28) .foregroundStyle(.secondary) } .disabled(isUploadingImage) + // Paste image from clipboard + Button(action: onPasteImage) { + Image(systemName: clipboardHasImage ? "doc.on.clipboard.fill" : "doc.on.clipboard") + .font(.system(size: 13, weight: .regular)) + .frame(maxWidth: .infinity, minHeight: 28) + .foregroundStyle(clipboardHasImage ? .primary : .tertiary) + } + .disabled(!clipboardHasImage || isUploadingImage) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: 28) } .background(Color(.systemBackground)) } @@ -483,8 +548,8 @@ struct FormattingToolbar: View { private var toolbarDivider: some View { Rectangle() .fill(Color(.separator)) - .frame(width: 0.5) - .padding(.vertical, 8) + .frame(width: 0.5, height: 16) + .padding(.horizontal, 2) } } @@ -514,13 +579,13 @@ struct FormatButton: View { Group { if let label { Text(label) - .font(.system(size: 12, weight: .medium, design: .rounded)) + .font(.system(size: 11, weight: .medium, design: .rounded)) } else if let systemImage { Image(systemName: systemImage) - .font(.system(size: 14, weight: .regular)) + .font(.system(size: 13, weight: .regular)) } } - .frame(maxWidth: .infinity, minHeight: 36) + .frame(maxWidth: .infinity, minHeight: 28) .foregroundStyle(.secondary) .contentShape(Rectangle()) .onTapGesture { @@ -566,7 +631,10 @@ struct MarkdownPreviewView: View { \(html) """ - webPage.load(html: fullHTML, baseURL: URL(string: "https://bookstack.example.com")!) + // Use the real server URL as base so WKWebView permits loading images from it. + let serverBase = UserDefaults.standard.string(forKey: "serverURL").flatMap { URL(string: $0) } + ?? URL(string: "about:blank")! + webPage.load(html: fullHTML, baseURL: serverBase) } /// Minimal Markdown → HTML converter for preview purposes. @@ -593,6 +661,14 @@ struct MarkdownPreviewView: View { with: "$1", options: .regularExpression) } + // Images (must come before links to avoid partial matches) + html = html.replacingOccurrences(of: #"!\[([^\]]*)\]\(([^)]+)\)"#, + with: "\"$1\"", + options: .regularExpression) + // Links + html = html.replacingOccurrences(of: #"\[([^\]]+)\]\(([^)]+)\)"#, + with: "$1", + options: .regularExpression) // Horizontal rule html = html.replacingOccurrences(of: "(?m)^---$", with: "
", options: .regularExpression) diff --git a/bookstax/Views/Library/BookDetailView.swift b/bookstax/Views/Library/BookDetailView.swift index e6c5bdb..4c5c803 100644 --- a/bookstax/Views/Library/BookDetailView.swift +++ b/bookstax/Views/Library/BookDetailView.swift @@ -142,7 +142,7 @@ struct BookDetailView: View { .accessibilityLabel("Add content") } } - .sheet(isPresented: $showNewPage) { + .fullScreenCover(isPresented: $showNewPage) { NavigationStack { PageEditorView(mode: .create(bookId: book.id)) } } .sheet(isPresented: $showNewChapter) { diff --git a/bookstax/Views/Library/LibraryView.swift b/bookstax/Views/Library/LibraryView.swift index 25a128c..318fa2b 100644 --- a/bookstax/Views/Library/LibraryView.swift +++ b/bookstax/Views/Library/LibraryView.swift @@ -125,6 +125,7 @@ struct BreadcrumbBar: View { .font(.subheadline.weight(.medium)) .foregroundStyle(theme.accentColor) .lineLimit(1) + .fixedSize() } .buttonStyle(.plain) } else { @@ -132,11 +133,12 @@ struct BreadcrumbBar: View { .font(.subheadline.weight(isLast ? .semibold : .medium)) .foregroundStyle(isLast ? .primary : .secondary) .lineLimit(1) + .fixedSize() } } } - .padding(.horizontal, 4) - .padding(.vertical, 2) + .padding(.horizontal, 16) + .padding(.vertical, 8) } } } diff --git a/bookstax/Views/Reader/PageReaderView.swift b/bookstax/Views/Reader/PageReaderView.swift index 90bb883..e1771e0 100644 --- a/bookstax/Views/Reader/PageReaderView.swift +++ b/bookstax/Views/Reader/PageReaderView.swift @@ -12,6 +12,8 @@ struct PageReaderView: View { @State private var isFetchingForEdit = false @State private var newComment = "" @State private var isPostingComment = false + @State private var commentError: String? = nil + @State private var commentsExpanded = false @AppStorage("showComments") private var showComments = true @Environment(\.colorScheme) private var colorScheme @@ -23,40 +25,59 @@ struct PageReaderView: View { } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - // Page header - VStack(alignment: .leading, spacing: 4) { - Text(resolvedPage.name) - .font(.largeTitle.bold()) - .padding(.horizontal) - .padding(.top) + VStack(spacing: 0) { + // Page header + VStack(alignment: .leading, spacing: 2) { + Text(resolvedPage.name) + .font(.title2.bold()) + .padding(.horizontal) + .padding(.top, 12) - Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative)) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.bottom, 8) - } - - // Web content - WebView(webPage) - .frame(minHeight: 400) - .frame(maxWidth: .infinity) + Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative)) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.bottom, 8) Divider() - .padding(.top) + } - // Comments section (hidden when user disabled in Settings) - if showComments { - DisclosureGroup { - commentsContent + // Web content — fills all space not taken by the comments inset + WebView(webPage) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + // Comments panel sits above the tab bar, inside the safe area + if showComments { + VStack(spacing: 0) { + Divider() + // Header row — always visible, tap to expand/collapse + Button { + withAnimation(.easeInOut(duration: 0.2)) { commentsExpanded.toggle() } } label: { - Label(String(format: L("reader.comments"), comments.count), systemImage: "bubble.left.and.bubble.right") - .font(.headline) + HStack { + Label(String(format: L("reader.comments"), comments.count), + systemImage: "bubble.left.and.bubble.right") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Spacer() + Image(systemName: commentsExpanded ? "chevron.down" : "chevron.up") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + + // Expandable body + if commentsExpanded { + Divider() + commentsContent + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .padding() } + .background(Color(.secondarySystemBackground)) } } .navigationBarTitleDisplayMode(.inline) @@ -89,7 +110,7 @@ struct PageReaderView: View { .accessibilityLabel(L("reader.share")) } } - .sheet(isPresented: $showEditor) { + .fullScreenCover(isPresented: $showEditor) { NavigationStack { if let fullPage { PageEditorView(mode: .edit(page: fullPage)) @@ -113,42 +134,75 @@ struct PageReaderView: View { @ViewBuilder private var commentsContent: some View { - VStack(alignment: .leading, spacing: 12) { - if isLoadingComments { - ProgressView() + VStack(spacing: 0) { + // Scrollable comment list — fixed height so layout is stable + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if isLoadingComments { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } else if comments.isEmpty { + Text(L("reader.comments.empty")) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding() + } else { + ForEach(comments) { comment in + CommentRow(comment: comment) + .padding(.horizontal) + .id(comment.id) + Divider().padding(.leading, 54) + } + } + } .frame(maxWidth: .infinity) - .padding() - } else if comments.isEmpty { - Text(L("reader.comments.empty")) - .font(.footnote) - .foregroundStyle(.secondary) - .padding() - } else { - ForEach(comments) { comment in - CommentRow(comment: comment) - Divider() + } + .frame(height: 180) + .onChange(of: comments.count) { + if let last = comments.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } } } - // New comment input - HStack(alignment: .bottom, spacing: 8) { - TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical) - .lineLimit(1...4) - .padding(10) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) - - Button { - Task { await postComment() } - } label: { - Image(systemName: "paperplane.fill") - .foregroundStyle(newComment.isEmpty ? Color.secondary : Color.blue) + // Input — always visible at the bottom of the panel + Divider() + VStack(spacing: 4) { + if let err = commentError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.top, 4) } - .disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment) - .accessibilityLabel("Post comment") + HStack(alignment: .bottom, spacing: 8) { + TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical) + .lineLimit(1...3) + .padding(8) + .background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) + + Button { + Task { await postComment() } + } label: { + if isPostingComment { + ProgressView().controlSize(.small) + } else { + Image(systemName: "paperplane.fill") + .foregroundStyle(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.secondary : Color.accentColor) + } + } + .disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment) + .accessibilityLabel("Post comment") + } + .padding(.horizontal) + .padding(.vertical, 8) } - .padding(.top, 8) + .background(Color(.secondarySystemBackground)) } - .padding(.top, 8) } // MARK: - Helpers @@ -186,19 +240,26 @@ struct PageReaderView: View { isLoadingComments = true comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? [] isLoadingComments = false + // Auto-expand if there are comments + if !comments.isEmpty { commentsExpanded = true } } private func postComment() async { let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } isPostingComment = true + commentError = nil AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader") do { - let comment = try await BookStackAPI.shared.postComment(pageId: page.id, text: text) - comments.append(comment) + try await BookStackAPI.shared.postComment(pageId: page.id, text: text) newComment = "" - AppLog(.info, "Comment posted on '\(page.name)'", category: "Reader") + AppLog(.info, "Comment posted on '\(page.name)' — reloading", category: "Reader") + await loadComments() + } catch let e as BookStackError { + commentError = e.localizedDescription + AppLog(.error, "Failed to post comment on '\(page.name)': \(e.localizedDescription)", category: "Reader") } catch { + commentError = error.localizedDescription AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader") } isPostingComment = false @@ -292,7 +353,7 @@ struct CommentRow: View { .font(.caption) .foregroundStyle(.tertiary) } - Text(comment.text) + Text(comment.html.strippingHTML) .font(.footnote) .foregroundStyle(.secondary) }