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)