Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions Docs/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Support und Datenschutzerklärung für LoopLine.">
<title>LoopLine – Support</title>
<style>
:root {
color-scheme: light dark;
--accent: #8f5b7a;
--background: #fffafb;
--surface: #ffffff;
--text: #252124;
--muted: #655f63;
--border: #e8dfe4;
}

@media (prefers-color-scheme: dark) {
:root {
--background: #1d191c;
--surface: #282328;
--text: #f5eef2;
--muted: #cfc3ca;
--border: #4a4047;
}
}

* {
box-sizing: border-box;
}

body {
margin: 0;
background: var(--background);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
}

main {
width: min(720px, calc(100% - 40px));
margin: 0 auto;
padding: 56px 0 72px;
}

header {
margin-bottom: 42px;
}

.eyebrow {
color: var(--accent);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}

h1, h2 {
line-height: 1.2;
}

h1 {
font-size: clamp(2.25rem, 8vw, 3.5rem);
letter-spacing: -0.04em;
margin: 10px 0 14px;
}

h2 {
font-size: 1.45rem;
margin: 0 0 14px;
}

p {
margin: 0 0 16px;
}

section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
margin: 20px 0;
padding: 26px;
}

a {
color: var(--accent);
font-weight: 650;
}

.muted {
color: var(--muted);
}

footer {
color: var(--muted);
font-size: 0.9rem;
margin-top: 36px;
}
</style>
</head>
<body>
<main>
<header>
<div class="eyebrow">LoopLine</div>
<h1>Support</h1>
<p class="muted">
Dein Begleiter für Strickprojekte: Muster organisieren,
Fortschritt verfolgen und entspannt stricken.
</p>
</header>

<section>
<h2>Hilfe und Feedback</h2>
<p>
Du hast eine Frage, möchtest Feedback geben oder ein Problem melden?
Schreib uns gerne an:
</p>
<p>
<a href="mailto:dev@jodoko.com@example.com">
dev@jodoko.com
</a>
</p>
<p class="muted">
Bitte nenne nach Möglichkeit dein iPhone- oder iPad-Modell,
die iOS-Version und eine kurze Beschreibung des Problems.
</p>
</section>

<section id="datenschutz">
<h2>Datenschutzerklärung</h2>
<p><strong>Stand: 4. Oktober 2026</strong></p>

<p>
LoopLine wurde entwickelt, damit du deine Strickprojekte und deinen
Fortschritt organisieren kannst.
</p>

<h3>Verarbeitung von Daten</h3>
<p>
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.
</p>

<h3>Von dir importierte Inhalte</h3>
<p>
Wenn du PDF-Dateien, Bilder oder Text in ein Projekt importierst, werden
diese Inhalte zur Nutzung in der App verarbeitet und gespeichert.
</p>

<h3>Kontaktaufnahme</h3>
<p>
Wenn du uns per E-Mail kontaktierst, verwenden wir deine Nachricht und
die darin enthaltenen Angaben ausschließlich zur Bearbeitung deiner
Anfrage.
</p>

<h3>Deine Rechte</h3>
<p>
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.
</p>

<h3>Änderungen</h3>
<p>
Wir können diese Datenschutzerklärung aktualisieren, wenn sich die
Funktionen von LoopLine oder rechtliche Anforderungen ändern.
</p>
</section>

<footer>
© 2026 Dominik de Jesus Moreira · LoopLine
</footer>
</main>
</body>
</html>
106 changes: 106 additions & 0 deletions LoopLine/Services/PDFMarkupStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<cgPoints.count {
let start = cgPoints[index - 1]
let end = cgPoints[index]
let visibleSegments = visibleSegments(from: start, to: end, outside: point, radius: eraserRadius)

if visibleSegments.count != 1 || visibleSegments.first?.lowerBound != 0 || visibleSegments.first?.upperBound != 1 {
removedPortion = true
}

for segment in visibleSegments {
let segmentStart = start.interpolated(toward: end, at: segment.lowerBound)
let segmentEnd = start.interpolated(toward: end, at: segment.upperBound)

if let lastPoint = activeFragment.last?.cgPoint, lastPoint.isApproximatelyEqual(to: segmentStart) {
activeFragment.append(PDFMarkupPoint(segmentEnd))
} else {
if activeFragment.count > 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<CGFloat>] {
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<CGFloat>]()
if entry > 0 {
segments.append(0...entry)
}
if exit < 1 {
segments.append(exit...1)
}
return segments
}
}

struct PDFMarkupPoint: Codable {
Expand Down Expand Up @@ -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
Expand Down
40 changes: 29 additions & 11 deletions LoopLine/Views/PDFReadingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -518,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

for stroke in pageStrokes {
let eraserRadius = max(selectedEraserWidth, 18) + CGFloat(stroke.width) / 2
let fragments = stroke.erasing(at: point, withRadius: eraserRadius)

guard !strokeIDsToRemove.isEmpty else { return }
guard fragments.count != 1 || fragments[0].id != stroke.id else {
updatedStrokes.append(stroke)
continue
}

storedStrokesByPage[pageIndex] = pageStrokes.filter { !strokeIDsToRemove.contains($0.id) }
strokeIDsToRemove.forEach { strokeID in
if let annotation = annotationsByStrokeID[strokeID] {
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 }

Expand Down
Loading
Loading