feat: Add text selection and annotation creation UI
Implement interactive text annotation feature: - Add text selection detection via JavaScript in WebView - Create AnnotationColorPicker with 4 color options (yellow, green, blue, red) - Integrate color picker sheet in bookmark detail views - Calculate text offsets for precise annotation positioning - Add onTextSelected callback for WebView component - Prepare UI for future API integration Users can now select text in articles and choose a highlight color. API integration for persisting annotations will follow.
This commit is contained in:
parent
cf06a3147d
commit
ec12815a51
84
readeck/UI/BookmarkDetail/AnnotationColorPicker.swift
Normal file
84
readeck/UI/BookmarkDetail/AnnotationColorPicker.swift
Normal file
@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AnnotationColorPicker: View {
|
||||
let selectedText: String
|
||||
let onColorSelected: (AnnotationColor) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Highlight Text")
|
||||
.font(.headline)
|
||||
|
||||
Text(selectedText)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(3)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
Text("Select Color")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ColorButton(color: .yellow, onTap: handleColorSelection)
|
||||
ColorButton(color: .green, onTap: handleColorSelection)
|
||||
ColorButton(color: .blue, onTap: handleColorSelection)
|
||||
ColorButton(color: .red, onTap: handleColorSelection)
|
||||
}
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
|
||||
private func handleColorSelection(_ color: AnnotationColor) {
|
||||
onColorSelected(color)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorButton: View {
|
||||
let color: AnnotationColor
|
||||
let onTap: (AnnotationColor) -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: { onTap(color) }) {
|
||||
Circle()
|
||||
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
|
||||
.frame(width: 50, height: 50)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnnotationColor: String, CaseIterable {
|
||||
case yellow = "yellow"
|
||||
case green = "green"
|
||||
case blue = "blue"
|
||||
case red = "red"
|
||||
|
||||
func swiftUIColor(isDark: Bool) -> Color {
|
||||
switch self {
|
||||
case .yellow:
|
||||
return isDark ? Color(red: 158/255, green: 117/255, blue: 4/255) : Color(red: 107/255, green: 79/255, blue: 3/255)
|
||||
case .green:
|
||||
return isDark ? Color(red: 132/255, green: 204/255, blue: 22/255) : Color(red: 57/255, green: 88/255, blue: 9/255)
|
||||
case .blue:
|
||||
return isDark ? Color(red: 9/255, green: 132/255, blue: 159/255) : Color(red: 7/255, green: 95/255, blue: 116/255)
|
||||
case .red:
|
||||
return isDark ? Color(red: 152/255, green: 43/255, blue: 43/255) : Color(red: 103/255, green: 29/255, blue: 29/255)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -35,6 +35,10 @@ struct BookmarkDetailLegacyView: View {
|
||||
@State private var showJumpToProgressButton: Bool = false
|
||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||
@State private var showingImageViewer = false
|
||||
@State private var showingColorPicker = false
|
||||
@State private var selectedText: String = ""
|
||||
@State private var selectedStartOffset: Int = 0
|
||||
@State private var selectedEndOffset: Int = 0
|
||||
|
||||
// MARK: - Envs
|
||||
|
||||
@ -88,7 +92,13 @@ struct BookmarkDetailLegacyView: View {
|
||||
webViewHeight = height
|
||||
}
|
||||
},
|
||||
selectedAnnotationId: viewModel.selectedAnnotationId
|
||||
selectedAnnotationId: viewModel.selectedAnnotationId,
|
||||
onTextSelected: { text, startOffset, endOffset in
|
||||
selectedText = text
|
||||
selectedStartOffset = startOffset
|
||||
selectedEndOffset = endOffset
|
||||
showingColorPicker = true
|
||||
}
|
||||
)
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
@ -268,6 +278,13 @@ struct BookmarkDetailLegacyView: View {
|
||||
.sheet(isPresented: $showingImageViewer) {
|
||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||
}
|
||||
.sheet(isPresented: $showingColorPicker) {
|
||||
AnnotationColorPicker(selectedText: selectedText) { color in
|
||||
// TODO: API call to create annotation will go here
|
||||
print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)")
|
||||
}
|
||||
.presentationDetents([.height(300)])
|
||||
}
|
||||
.onChange(of: showingFontSettings) { _, isShowing in
|
||||
if !isShowing {
|
||||
// Reload settings when sheet is dismissed
|
||||
|
||||
@ -20,6 +20,10 @@ struct BookmarkDetailView2: View {
|
||||
@State private var showJumpToProgressButton: Bool = false
|
||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||
@State private var showingImageViewer = false
|
||||
@State private var showingColorPicker = false
|
||||
@State private var selectedText: String = ""
|
||||
@State private var selectedStartOffset: Int = 0
|
||||
@State private var selectedEndOffset: Int = 0
|
||||
|
||||
// MARK: - Envs
|
||||
|
||||
@ -59,6 +63,13 @@ struct BookmarkDetailView2: View {
|
||||
.sheet(isPresented: $showingImageViewer) {
|
||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||
}
|
||||
.sheet(isPresented: $showingColorPicker) {
|
||||
AnnotationColorPicker(selectedText: selectedText) { color in
|
||||
// TODO: API call to create annotation will go here
|
||||
print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)")
|
||||
}
|
||||
.presentationDetents([.height(300)])
|
||||
}
|
||||
.onChange(of: showingFontSettings) { _, isShowing in
|
||||
if !isShowing {
|
||||
Task {
|
||||
|
||||
@ -7,6 +7,7 @@ struct WebView: UIViewRepresentable {
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
var onScroll: ((Double) -> Void)? = nil
|
||||
var selectedAnnotationId: String?
|
||||
var onTextSelected: ((String, Int, Int) -> Void)? = nil
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
@ -29,8 +30,10 @@ struct WebView: UIViewRepresentable {
|
||||
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "textSelected")
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
context.coordinator.onTextSelected = onTextSelected
|
||||
|
||||
return webView
|
||||
}
|
||||
@ -38,6 +41,7 @@ struct WebView: UIViewRepresentable {
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
context.coordinator.onTextSelected = onTextSelected
|
||||
|
||||
let isDarkMode = colorScheme == .dark
|
||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||
@ -309,6 +313,28 @@ struct WebView: UIViewRepresentable {
|
||||
img.addEventListener('load', debouncedHeightUpdate);
|
||||
});
|
||||
|
||||
// Text selection detection
|
||||
document.addEventListener('selectionchange', function() {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().length > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedText = selection.toString();
|
||||
|
||||
// Calculate character offset from start of body
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(document.body);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
const startOffset = preRange.toString().length;
|
||||
const endOffset = startOffset + selectedText.length;
|
||||
|
||||
window.webkit.messageHandlers.textSelected.postMessage({
|
||||
text: selectedText,
|
||||
startOffset: startOffset,
|
||||
endOffset: endOffset
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to selected annotation
|
||||
\(generateScrollToAnnotationJS())
|
||||
</script>
|
||||
@ -389,6 +415,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
// Callbacks
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
var onScroll: ((Double) -> Void)?
|
||||
var onTextSelected: ((String, Int, Int) -> Void)?
|
||||
|
||||
// Height management
|
||||
var lastHeight: CGFloat = 0
|
||||
@ -430,6 +457,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
self.handleScrollProgress(progress: progress)
|
||||
}
|
||||
}
|
||||
if message.name == "textSelected", let body = message.body as? [String: Any],
|
||||
let text = body["text"] as? String,
|
||||
let startOffset = body["startOffset"] as? Int,
|
||||
let endOffset = body["endOffset"] as? Int {
|
||||
DispatchQueue.main.async {
|
||||
self.onTextSelected?(text, startOffset, endOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHeightUpdate(height: CGFloat) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user