refactor: Centralize annotation colors and improve color consistency

- Move AnnotationColor enum to Constants.swift for centralized color management
- Add hexColor property to provide hex values for JavaScript overlays
- Add cssColorWithOpacity method for flexible opacity control
- Update NativeWebView and WebView to use centralized color values
- Replace modal color picker with inline overlay for better UX
- Implement annotation creation directly from text selection
- Add API endpoint for creating annotations with selectors
This commit is contained in:
Ilyas Hallak 2025-10-25 09:19:49 +02:00
parent 1b9f79bccc
commit b77e4e3e9f
9 changed files with 720 additions and 144 deletions

View File

@ -19,6 +19,7 @@ protocol PAPI {
func searchBookmarks(search: String) async throws -> BookmarksPageDto
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
}
class API: PAPI {
@ -459,6 +460,32 @@ class API: PAPI {
logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)")
return result
}
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto {
logger.debug("Creating annotation for bookmark: \(bookmarkId)")
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
let bodyDict: [String: Any] = [
"color": color,
"start_offset": startOffset,
"end_offset": endOffset,
"start_selector": startSelector,
"end_selector": endSelector
]
let bodyData = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
let result = try await makeJSONRequest(
endpoint: endpoint,
method: .POST,
body: bodyData,
responseType: AnnotationDto.self
)
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
return result
}
}
enum HTTPMethod: String {

View File

@ -0,0 +1,45 @@
import SwiftUI
struct AnnotationColorOverlay: View {
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 8) {
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: onColorSelected)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2)
)
}
private 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: 36, height: 36)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
)
}
}
}
}
#Preview {
AnnotationColorOverlay { color in
print("Selected: \(color)")
}
.padding()
}

View File

@ -24,10 +24,9 @@ struct AnnotationColorPicker: View {
.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)
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: handleColorSelection)
}
}
Button("Cancel") {
@ -62,23 +61,3 @@ struct ColorButton: View {
}
}
}
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)
}
}
}

View File

@ -35,10 +35,6 @@ 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
@ -93,11 +89,18 @@ struct BookmarkDetailLegacyView: View {
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onTextSelected: { text, startOffset, endOffset in
selectedText = text
selectedStartOffset = startOffset
selectedEndOffset = endOffset
showingColorPicker = true
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
}
)
.frame(height: webViewHeight)
@ -278,13 +281,6 @@ 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

View File

@ -20,10 +20,6 @@ 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
@ -63,13 +59,6 @@ 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 {
@ -472,11 +461,18 @@ struct BookmarkDetailView2: View {
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onTextSelected: { text, startOffset, endOffset in
selectedText = text
selectedStartOffset = startOffset
selectedEndOffset = endOffset
showingColorPicker = true
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
}
)
.frame(height: webViewHeight)

View File

@ -8,6 +8,7 @@ class BookmarkDetailViewModel {
private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -29,6 +30,7 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.factory = factory
readProgressSubject
@ -138,4 +140,22 @@ class BookmarkDetailViewModel {
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
readProgressSubject.send((id, progress, anchor))
}
@MainActor
func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async {
do {
let annotation = try await api.createAnnotation(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
print("✅ Annotation created: \(annotation.id)")
} catch {
print("❌ Failed to create annotation: \(error)")
errorMessage = "Error creating annotation"
}
}
}

View File

@ -10,7 +10,53 @@
//
import Foundation
import SwiftUI
struct Constants {
// Empty for now - can be used for other constants in the future
// Annotation colors
static let annotationColors: [AnnotationColor] = [.yellow, .green, .blue, .red]
}
enum AnnotationColor: String, CaseIterable, Codable {
case yellow = "yellow"
case green = "green"
case blue = "blue"
case red = "red"
// Base hex color for buttons and overlays
var hexColor: String {
switch self {
case .yellow: return "#D4A843"
case .green: return "#6FB546"
case .blue: return "#4A9BB8"
case .red: return "#C84848"
}
}
// RGB values for SwiftUI Color
private var rgb: (red: Double, green: Double, blue: Double) {
switch self {
case .yellow: return (212, 168, 67)
case .green: return (111, 181, 70)
case .blue: return (74, 155, 184)
case .red: return (200, 72, 72)
}
}
func swiftUIColor(isDark: Bool) -> Color {
let (r, g, b) = rgb
return Color(red: r/255, green: g/255, blue: b/255)
}
// CSS rgba string for JavaScript (for highlighting)
func cssColor(isDark: Bool) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), 0.3)"
}
// CSS rgba string with custom opacity
func cssColorWithOpacity(_ opacity: Double) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), \(opacity))"
}
}

View File

@ -12,7 +12,7 @@ struct NativeWebView: View {
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onTextSelected: ((String, Int, Int) -> Void)? = nil
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
@ -22,7 +22,7 @@ struct NativeWebView: View {
.scrollDisabled(true) // Disable internal scrolling
.onAppear {
loadStyledContent()
setupTextSelectionCallback()
setupAnnotationMessageHandler()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
@ -45,34 +45,22 @@ struct NativeWebView: View {
}
}
private func setupTextSelectionCallback() {
guard let onTextSelected = onTextSelected else { return }
private func setupAnnotationMessageHandler() {
guard let onAnnotationCreated = onAnnotationCreated else { return }
// Poll for text selection using JavaScript
// Poll for annotation messages from JavaScript
Task { @MainActor in
let page = webPage // Capture the webPage
let page = webPage
while true {
try? await Task.sleep(nanoseconds: 300_000_000) // Check every 0.3s
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
let script = """
return (function() {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const selectedText = selection.toString();
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(range.startContainer, range.startOffset);
const startOffset = preRange.toString().length;
const endOffset = startOffset + selectedText.length;
return {
text: selectedText,
startOffset: startOffset,
endOffset: endOffset
};
if (window.__pendingAnnotation) {
const data = window.__pendingAnnotation;
window.__pendingAnnotation = null;
return data;
}
return null;
})();
@ -80,10 +68,13 @@ struct NativeWebView: View {
do {
if let result = try await page.callJavaScript(script) as? [String: Any],
let color = result["color"] as? String,
let text = result["text"] as? String,
let startOffset = result["startOffset"] as? Int,
let endOffset = result["endOffset"] as? Int {
onTextSelected(text, startOffset, endOffset)
let endOffset = result["endOffset"] as? Int,
let startSelector = result["startSelector"] as? String,
let endSelector = result["endSelector"] as? String {
onAnnotationCreated(color, text, startOffset, endOffset, startSelector, endSelector)
}
} catch {
// Silently continue polling
@ -260,38 +251,38 @@ struct NativeWebView: View {
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)");
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)");
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)");
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)");
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)");
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)");
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)");
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)");
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</style>
</head>
@ -339,11 +330,11 @@ struct NativeWebView: View {
scheduleHeightCheck();
// Text selection detection
\(generateTextSelectionJS())
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
@ -376,9 +367,247 @@ struct NativeWebView: View {
}
}
private func generateTextSelectionJS() -> String {
// Not needed for iOS 26 - we use polling instead
return ""
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// For NativeWebView: use global variable for polling
window.__pendingAnnotation = {
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
};
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
private func generateScrollToAnnotationJS() -> String {

View File

@ -7,7 +7,7 @@ struct WebView: UIViewRepresentable {
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onTextSelected: ((String, Int, Int) -> Void)? = nil
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
@ -30,10 +30,11 @@ 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")
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onTextSelected = onTextSelected
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.webView = webView
return webView
}
@ -41,7 +42,7 @@ struct WebView: UIViewRepresentable {
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onTextSelected = onTextSelected
context.coordinator.onAnnotationCreated = onAnnotationCreated
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
@ -250,38 +251,38 @@ struct WebView: UIViewRepresentable {
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)");
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)");
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)");
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)");
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)");
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)");
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)");
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)");
box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)");
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</style>
</head>
@ -313,30 +314,11 @@ 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())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
@ -349,6 +331,7 @@ struct WebView: UIViewRepresentable {
webView.navigationDelegate = nil
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
webView.loadHTMLString("", baseURL: nil)
coordinator.cleanup()
}
@ -409,13 +392,264 @@ struct WebView: UIViewRepresentable {
}
"""
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
let yellowColor = AnnotationColor.yellow.cssColor(isDark: isDarkMode)
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// Send to Swift with selectors
window.webkit.messageHandlers.annotationCreated.postMessage({
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
});
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
}
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
// Callbacks
var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var onTextSelected: ((String, Int, Int) -> Void)?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
// WebView reference
weak var webView: WKWebView?
// Height management
var lastHeight: CGFloat = 0
@ -457,12 +691,15 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
self.handleScrollProgress(progress: progress)
}
}
if message.name == "textSelected", let body = message.body as? [String: Any],
if message.name == "annotationCreated", let body = message.body as? [String: Any],
let color = body["color"] as? String,
let text = body["text"] as? String,
let startOffset = body["startOffset"] as? Int,
let endOffset = body["endOffset"] as? Int {
let endOffset = body["endOffset"] as? Int,
let startSelector = body["startSelector"] as? String,
let endSelector = body["endSelector"] as? String {
DispatchQueue.main.async {
self.onTextSelected?(text, startOffset, endOffset)
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
}
}
}
@ -532,13 +769,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
func cleanup() {
guard !isCleanedUp else { return }
isCleanedUp = true
scrollEndTimer?.invalidate()
scrollEndTimer = nil
heightUpdateTimer?.invalidate()
heightUpdateTimer = nil
onHeightChange = nil
onScroll = nil
onAnnotationCreated = nil
}
}