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 showJumpToProgressButton: Bool = false
|
||||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||||
@State private var showingImageViewer = false
|
@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
|
// MARK: - Envs
|
||||||
|
|
||||||
@ -88,7 +92,13 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
webViewHeight = height
|
webViewHeight = height
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectedAnnotationId: viewModel.selectedAnnotationId
|
selectedAnnotationId: viewModel.selectedAnnotationId,
|
||||||
|
onTextSelected: { text, startOffset, endOffset in
|
||||||
|
selectedText = text
|
||||||
|
selectedStartOffset = startOffset
|
||||||
|
selectedEndOffset = endOffset
|
||||||
|
showingColorPicker = true
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
.cornerRadius(14)
|
.cornerRadius(14)
|
||||||
@ -268,6 +278,13 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
.sheet(isPresented: $showingImageViewer) {
|
.sheet(isPresented: $showingImageViewer) {
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
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
|
.onChange(of: showingFontSettings) { _, isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
// Reload settings when sheet is dismissed
|
// Reload settings when sheet is dismissed
|
||||||
|
|||||||
@ -20,6 +20,10 @@ struct BookmarkDetailView2: View {
|
|||||||
@State private var showJumpToProgressButton: Bool = false
|
@State private var showJumpToProgressButton: Bool = false
|
||||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||||
@State private var showingImageViewer = false
|
@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
|
// MARK: - Envs
|
||||||
|
|
||||||
@ -59,6 +63,13 @@ struct BookmarkDetailView2: View {
|
|||||||
.sheet(isPresented: $showingImageViewer) {
|
.sheet(isPresented: $showingImageViewer) {
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
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
|
.onChange(of: showingFontSettings) { _, isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
let onHeightChange: (CGFloat) -> Void
|
let onHeightChange: (CGFloat) -> Void
|
||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
var selectedAnnotationId: String?
|
var selectedAnnotationId: String?
|
||||||
|
var onTextSelected: ((String, Int, Int) -> Void)? = nil
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
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: "heightUpdate")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||||
|
webView.configuration.userContentController.add(context.coordinator, name: "textSelected")
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
context.coordinator.onTextSelected = onTextSelected
|
||||||
|
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
@ -38,6 +41,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
context.coordinator.onTextSelected = onTextSelected
|
||||||
|
|
||||||
let isDarkMode = colorScheme == .dark
|
let isDarkMode = colorScheme == .dark
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||||
@ -309,6 +313,28 @@ struct WebView: UIViewRepresentable {
|
|||||||
img.addEventListener('load', debouncedHeightUpdate);
|
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
|
// Scroll to selected annotation
|
||||||
\(generateScrollToAnnotationJS())
|
\(generateScrollToAnnotationJS())
|
||||||
</script>
|
</script>
|
||||||
@ -389,6 +415,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
// Callbacks
|
// Callbacks
|
||||||
var onHeightChange: ((CGFloat) -> Void)?
|
var onHeightChange: ((CGFloat) -> Void)?
|
||||||
var onScroll: ((Double) -> Void)?
|
var onScroll: ((Double) -> Void)?
|
||||||
|
var onTextSelected: ((String, Int, Int) -> Void)?
|
||||||
|
|
||||||
// Height management
|
// Height management
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
@ -430,6 +457,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
self.handleScrollProgress(progress: progress)
|
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) {
|
private func handleHeightUpdate(height: CGFloat) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user