import SwiftUI import WebKit struct WebView: UIViewRepresentable { let htmlContent: String let settings: Settings let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() // Enable text selection and copy functionality let preferences = WKWebpagePreferences() preferences.allowsContentJavaScript = true configuration.defaultWebpagePreferences = preferences let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator webView.scrollView.isScrollEnabled = false webView.isOpaque = false webView.backgroundColor = UIColor.clear // Allow text selection and copying webView.allowsBackForwardNavigationGestures = false webView.allowsLinkPreview = true webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress") webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated") context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll context.coordinator.onAnnotationCreated = onAnnotationCreated context.coordinator.webView = webView return webView } func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll context.coordinator.onAnnotationCreated = onAnnotationCreated let isDarkMode = colorScheme == .dark let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif) // Clean up problematic HTML that kills performance let cleanedHTML = htmlContent // Remove Google attributes that cause navigation events .replacingOccurrences(of: #"\s*jsaction="[^"]*""#, with: "", options: .regularExpression) .replacingOccurrences(of: #"\s*jscontroller="[^"]*""#, with: "", options: .regularExpression) .replacingOccurrences(of: #"\s*jsname="[^"]*""#, with: "", options: .regularExpression) // Remove unnecessary IDs that bloat the DOM .replacingOccurrences(of: #"\s*id="[^"]*""#, with: "", options: .regularExpression) // Remove tabindex from non-interactive elements .replacingOccurrences(of: #"\s*tabindex="[^"]*""#, with: "", options: .regularExpression) // Remove role=button from figures (causes false click targets) .replacingOccurrences(of: #"\s*role="button""#, with: "", options: .regularExpression) // Fix invalid nested

tags inside


            .replacingOccurrences(of: #"
]*>([^<]*)

"#, with: "

$1\n", options: .regularExpression)
            .replacingOccurrences(of: #"

([^<]*)
"#, with: "\n$1
", options: .regularExpression) let styledHTML = """ \(cleanedHTML) """ webView.loadHTMLString(styledHTML, baseURL: nil) } func dismantleUIView(_ webView: WKWebView, coordinator: WebViewCoordinator) { webView.stopLoading() 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() } func makeCoordinator() -> WebViewCoordinator { WebViewCoordinator() } private func getFontSize(from fontSize: FontSize) -> Int { switch fontSize { case .small: return 14 case .medium: return 16 case .large: return 18 case .extraLarge: return 20 } } private func getFontFamily(from fontFamily: FontFamily) -> String { switch fontFamily { case .system: return "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" case .serif: return "'Times New Roman', Times, 'Liberation Serif', serif" case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif" case .monospace: return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace" } } private func generateScrollToAnnotationJS() -> String { guard let selectedId = selectedAnnotationId else { return "" } return """ // Scroll to selected annotation and add selected class function scrollToAnnotation() { // Remove 'selected' class from all annotations document.querySelectorAll('rd-annotation.selected').forEach(el => { el.classList.remove('selected'); }); // Find and highlight selected annotation const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); if (selectedElement) { selectedElement.classList.add('selected'); setTimeout(() => { selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', scrollToAnnotation); } else { setTimeout(scrollToAnnotation, 300); } """ } 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 onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? // WebView reference weak var webView: WKWebView? // Height management var lastHeight: CGFloat = 0 var pendingHeight: CGFloat = 0 var heightUpdateTimer: Timer? // Scroll management var isScrolling: Bool = false var scrollVelocity: Double = 0 var lastScrollTime: Date = Date() var scrollEndTimer: Timer? // Lifecycle private var isCleanedUp = false deinit { cleanup() } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { UIApplication.shared.open(url) decisionHandler(.cancel) return } } decisionHandler(.allow) } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "heightUpdate", let height = message.body as? CGFloat { DispatchQueue.main.async { self.handleHeightUpdate(height: height) } } if message.name == "scrollProgress", let progress = message.body as? Double { DispatchQueue.main.async { self.handleScrollProgress(progress: progress) } } 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 startSelector = body["startSelector"] as? String, let endSelector = body["endSelector"] as? String { DispatchQueue.main.async { self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector) } } } private func handleHeightUpdate(height: CGFloat) { // Store the pending height pendingHeight = height // If we're actively scrolling, defer the height update if isScrolling { return } // Apply height update immediately if not scrolling applyHeightUpdate(height: height) } private func handleScrollProgress(progress: Double) { let now = Date() let timeDelta = now.timeIntervalSince(lastScrollTime) // Calculate scroll velocity to detect fast scrolling if timeDelta > 0 { scrollVelocity = abs(progress) / timeDelta } lastScrollTime = now isScrolling = true // Longer delay for scroll end detection, especially during fast scrolling let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5 scrollEndTimer?.invalidate() scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in self?.handleScrollEnd() } onScroll?(progress) } private func handleScrollEnd() { isScrolling = false scrollVelocity = 0 // Apply any pending height update after scrolling ends if pendingHeight != lastHeight && pendingHeight > 0 { // Add small delay to ensure scroll has fully stopped heightUpdateTimer?.invalidate() heightUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in guard let self = self else { return } self.applyHeightUpdate(height: self.pendingHeight) } } } private func applyHeightUpdate(height: CGFloat) { // Only update if height actually changed significantly let heightDifference = abs(height - lastHeight) if heightDifference < 5 { // Ignore tiny height changes that cause flicker return } lastHeight = height onHeightChange?(height) } func cleanup() { guard !isCleanedUp else { return } isCleanedUp = true scrollEndTimer?.invalidate() scrollEndTimer = nil heightUpdateTimer?.invalidate() heightUpdateTimer = nil onHeightChange = nil onScroll = nil onAnnotationCreated = nil } }