From 1d8fc83a8947e2d1892aee6c76c6f5b1cbd2515f Mon Sep 17 00:00:00 2001 From: Dominik Moreira Date: Sun, 4 Oct 2026 18:02:44 +0200 Subject: [PATCH 1/3] Preserve PDF zoom when enabling markup --- LoopLine/Views/PDFReadingView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/LoopLine/Views/PDFReadingView.swift b/LoopLine/Views/PDFReadingView.swift index bc1b8d3..7660e46 100644 --- a/LoopLine/Views/PDFReadingView.swift +++ b/LoopLine/Views/PDFReadingView.swift @@ -268,8 +268,6 @@ private struct PDFKitView: UIViewRepresentable { } func updateUIView(_ pdfView: PDFView, context: Context) { - pdfView.autoScales = true - if context.coordinator.url != url || pdfView.document == nil { context.coordinator.reset(for: url) pdfView.document = PDFDocument(url: url) From 7a313e3641f8256a2abe2ef2a827c86d4306cd0e Mon Sep 17 00:00:00 2001 From: Dominik Moreira Date: Sun, 4 Oct 2026 18:31:15 +0200 Subject: [PATCH 2/3] Add html support page --- Docs/index.html | 179 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 Docs/index.html diff --git a/Docs/index.html b/Docs/index.html new file mode 100644 index 0000000..3219ddd --- /dev/null +++ b/Docs/index.html @@ -0,0 +1,179 @@ + + + + + + + LoopLine – Support + + + +
+
+
LoopLine
+

Support

+

+ Dein Begleiter für Strickprojekte: Muster organisieren, + Fortschritt verfolgen und entspannt stricken. +

+
+ +
+

Hilfe und Feedback

+

+ Du hast eine Frage, möchtest Feedback geben oder ein Problem melden? + Schreib uns gerne an: +

+

+ + dev@jodoko.com + +

+

+ Bitte nenne nach Möglichkeit dein iPhone- oder iPad-Modell, + die iOS-Version und eine kurze Beschreibung des Problems. +

+
+ +
+

Datenschutzerklärung

+

Stand: 4. Oktober 2026

+ +

+ LoopLine wurde entwickelt, damit du deine Strickprojekte und deinen + Fortschritt organisieren kannst. +

+ +

Verarbeitung von Daten

+

+ LoopLine speichert die von dir angelegten Projekte, Musterinhalte und + Fortschrittsdaten auf deinem Gerät. Die App verkauft keine persönlichen + Daten und verwendet sie nicht für Werbung. +

+ +

Von dir importierte Inhalte

+

+ Wenn du PDF-Dateien, Bilder oder Text in ein Projekt importierst, werden + diese Inhalte zur Nutzung in der App verarbeitet und gespeichert. +

+ +

Kontaktaufnahme

+

+ Wenn du uns per E-Mail kontaktierst, verwenden wir deine Nachricht und + die darin enthaltenen Angaben ausschließlich zur Bearbeitung deiner + Anfrage. +

+ +

Deine Rechte

+

+ Du kannst deine in der App gespeicherten Projekte jederzeit innerhalb + der App löschen. Bei Fragen zum Datenschutz kontaktiere uns bitte über + die oben genannte E-Mail-Adresse. +

+ +

Änderungen

+

+ Wir können diese Datenschutzerklärung aktualisieren, wenn sich die + Funktionen von LoopLine oder rechtliche Anforderungen ändern. +

+
+ +
+ © 2026 Dominik de Jesus Moreira · LoopLine +
+
+ + From 5e17fdc85fe8d5ac133fd479470c5885eb0f18da Mon Sep 17 00:00:00 2001 From: Dominik Moreira Date: Sun, 4 Oct 2026 18:31:57 +0200 Subject: [PATCH 3/3] improve erasing marked sections --- LoopLine/Services/PDFMarkupStorage.swift | 106 ++++++++++++++++++++ LoopLine/Views/PDFReadingView.swift | 38 +++++-- LoopLineTests/ProjectPersistenceTests.swift | 31 ++++++ 3 files changed, 166 insertions(+), 9 deletions(-) diff --git a/LoopLine/Services/PDFMarkupStorage.swift b/LoopLine/Services/PDFMarkupStorage.swift index 189bc9d..b4fc77e 100644 --- a/LoopLine/Services/PDFMarkupStorage.swift +++ b/LoopLine/Services/PDFMarkupStorage.swift @@ -51,6 +51,101 @@ struct PDFMarkupStroke: Codable, Identifiable { return false } + + func erasing(at point: CGPoint, withRadius radius: CGFloat) -> [PDFMarkupStroke] { + guard points.count > 1 else { return [self] } + + let eraserRadius = max(radius, 0) + let cgPoints = points.map(\.cgPoint) + var fragments = [[PDFMarkupPoint]]() + var activeFragment = [PDFMarkupPoint]() + var removedPortion = false + + for index in 1.. 1 { + fragments.append(activeFragment) + } + activeFragment = [PDFMarkupPoint(segmentStart), PDFMarkupPoint(segmentEnd)] + } + } + + if visibleSegments.isEmpty, activeFragment.count > 1 { + fragments.append(activeFragment) + activeFragment.removeAll() + } + } + + if activeFragment.count > 1 { + fragments.append(activeFragment) + } + + guard removedPortion else { return [self] } + + return fragments.map { + PDFMarkupStroke( + pageIndex: pageIndex, + points: $0, + color: color, + width: width, + isMarker: isMarker + ) + } + } + + private func visibleSegments( + from start: CGPoint, + to end: CGPoint, + outside center: CGPoint, + radius: CGFloat + ) -> [ClosedRange] { + let deltaX = end.x - start.x + let deltaY = end.y - start.y + let lengthSquared = deltaX * deltaX + deltaY * deltaY + guard lengthSquared > 0 else { + return hypot(start.x - center.x, start.y - center.y) > radius ? [0...1] : [] + } + + let offsetX = start.x - center.x + let offsetY = start.y - center.y + let projection = offsetX * deltaX + offsetY * deltaY + let discriminant = projection * projection - lengthSquared * (offsetX * offsetX + offsetY * offsetY - radius * radius) + + guard discriminant > 0 else { + return [0...1] + } + + let root = sqrt(discriminant) + let entry = max(0, min(1, (-projection - root) / lengthSquared)) + let exit = max(0, min(1, (-projection + root) / lengthSquared)) + + guard entry < exit else { + return [0...1] + } + + var segments = [ClosedRange]() + if entry > 0 { + segments.append(0...entry) + } + if exit < 1 { + segments.append(exit...1) + } + return segments + } } struct PDFMarkupPoint: Codable { @@ -104,6 +199,17 @@ struct PDFMarkupColor: Codable { } private extension CGPoint { + func interpolated(toward point: CGPoint, at progress: CGFloat) -> CGPoint { + CGPoint( + x: x + (point.x - x) * progress, + y: y + (point.y - y) * progress + ) + } + + func isApproximatelyEqual(to point: CGPoint, tolerance: CGFloat = 0.001) -> Bool { + hypot(x - point.x, y - point.y) <= tolerance + } + func distance(toSegmentFrom start: CGPoint, to end: CGPoint) -> CGFloat { let dx = end.x - start.x let dy = end.y - start.y diff --git a/LoopLine/Views/PDFReadingView.swift b/LoopLine/Views/PDFReadingView.swift index 7660e46..fedf14b 100644 --- a/LoopLine/Views/PDFReadingView.swift +++ b/LoopLine/Views/PDFReadingView.swift @@ -516,25 +516,45 @@ private struct PDFKitView: UIViewRepresentable { return } - let removalDistance = max(selectedLineWidth, 18) - let strokeIDsToRemove = pageStrokes - .filter { $0.contains(point, within: removalDistance) } - .map(\.id) + var updatedStrokes = [PDFMarkupStroke]() + var didEraseMarkup = false - guard !strokeIDsToRemove.isEmpty else { return } + for stroke in pageStrokes { + let eraserRadius = max(selectedEraserWidth, 18) + CGFloat(stroke.width) / 2 + let fragments = stroke.erasing(at: point, withRadius: eraserRadius) - storedStrokesByPage[pageIndex] = pageStrokes.filter { !strokeIDsToRemove.contains($0.id) } - strokeIDsToRemove.forEach { strokeID in - if let annotation = annotationsByStrokeID[strokeID] { + guard fragments.count != 1 || fragments[0].id != stroke.id else { + updatedStrokes.append(stroke) + continue + } + + didEraseMarkup = true + if let annotation = annotationsByStrokeID[stroke.id] { annotation.shouldDisplay = false page.removeAnnotation(annotation) - annotationsByStrokeID[strokeID] = nil + annotationsByStrokeID[stroke.id] = nil + } + + for fragment in fragments { + let annotation = PDFMarkupAnnotation(stroke: fragment, pageBounds: page.bounds(for: .cropBox)) + page.addAnnotation(annotation) + annotationsByStrokeID[fragment.id] = annotation + updatedStrokes.append(fragment) } } + + guard didEraseMarkup else { return } + + storedStrokesByPage[pageIndex] = updatedStrokes notifyMarkupChanged(on: page) scheduleMarkupSave() } + private var selectedEraserWidth: CGFloat { + guard let tool = currentTool as? PKEraserTool else { return 18 } + return max(tool.width, 1) + } + private func loadStoredMarkupsIfNeeded(in pdfView: PDFView) { guard !hasLoadedStoredMarkups, let document = pdfView.document else { return } diff --git a/LoopLineTests/ProjectPersistenceTests.swift b/LoopLineTests/ProjectPersistenceTests.swift index 9c38aed..62abc66 100644 --- a/LoopLineTests/ProjectPersistenceTests.swift +++ b/LoopLineTests/ProjectPersistenceTests.swift @@ -6,10 +6,41 @@ // import SwiftData +import UIKit import XCTest @testable import LoopLine final class ProjectPersistenceTests: XCTestCase { + func testPartialErasingSplitsMarkupStroke() { + let stroke = PDFMarkupStroke( + pageIndex: 0, + points: [PDFMarkupPoint(CGPoint(x: 0, y: 0)), PDFMarkupPoint(CGPoint(x: 100, y: 0))], + color: PDFMarkupColor(UIColor(red: 1, green: 0.8, blue: 0, alpha: 0.38)), + width: 10, + isMarker: true + ) + + let fragments = stroke.erasing(at: CGPoint(x: 50, y: 0), withRadius: 15) + + XCTAssertEqual(fragments.count, 2) + XCTAssertLessThan(fragments[0].points.last!.cgPoint.x, 50) + XCTAssertGreaterThan(fragments[1].points.first!.cgPoint.x, 50) + } + + func testErasingOutsideMarkupKeepsOriginalStroke() { + let stroke = PDFMarkupStroke( + pageIndex: 0, + points: [PDFMarkupPoint(CGPoint(x: 0, y: 0)), PDFMarkupPoint(CGPoint(x: 100, y: 0))], + color: PDFMarkupColor(UIColor(red: 1, green: 0.8, blue: 0, alpha: 0.38)), + width: 10, + isMarker: true + ) + + let fragments = stroke.erasing(at: CGPoint(x: 50, y: 50), withRadius: 10) + + XCTAssertEqual(fragments.first?.id, stroke.id) + } + @MainActor func testPDFURLRequiresPDFSourceAndExistingFile() throws { let fileURL = FileManager.default.temporaryDirectory