diff --git a/.gitignore b/.gitignore index 52fe2f71..dfcae458 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,13 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output + + +.DS_Store +._.DS_Store +**/.DS_Store +**/._.DS_Store + +timeline.xctimeline +playground.xcworkspace +*.xcconfig diff --git a/NetworkUnitTest/NetworkUnitTest.swift b/NetworkUnitTest/NetworkUnitTest.swift new file mode 100644 index 00000000..36343045 --- /dev/null +++ b/NetworkUnitTest/NetworkUnitTest.swift @@ -0,0 +1,30 @@ +// +// NetworkUnitTest.swift +// NetworkUnitTest +// +// Created by 문인범 on 11/4/24. +// + +import Testing +import Foundation +@testable import Reazy + +struct NetworkUnitTest { + + @Test func testNetwork() async throws { + let pdfURL = Bundle.main.url(forResource: "engPD5", withExtension: "pdf")! + let data: PDFInfo = try await NetworkManager.fetchPDFExtraction(process: .processHeaderDocument, pdfURL: pdfURL) + + print(data.names) + + #expect(data.names != nil) + } + + @Test func testNetwork2() async throws { + let pdfURL = Bundle.main.url(forResource: "engPD5", withExtension: "pdf")! + let data: PDFLayout = try await NetworkManager.fetchPDFExtraction(process: .processFulltextDocument, pdfURL: pdfURL) + + #expect(data.div.count > 0) + } + +} diff --git a/Project/Preview Content/Preview Assets.xcassets/Contents.json b/Project/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Project/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Project/Reazy/App/AppView.swift b/Project/Reazy/App/AppView.swift new file mode 100644 index 00000000..6afd8950 --- /dev/null +++ b/Project/Reazy/App/AppView.swift @@ -0,0 +1,82 @@ +// +// ReazyApp.swift +// Reazy +// +// Created by 문인범 on 10/14/24. +// + +import SwiftUI + +@main +struct AppView: App { + // AppDelegate + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + // Navigation 컨트롤 + @StateObject private var navigationCoordinator: NavigationCoordinator = .init() + + @StateObject private var pdfFileManager: PDFFileManager = .init(paperService: .shared) + + var body: some Scene { + WindowGroup { + NavigationStack(path: $navigationCoordinator.path) { + navigationCoordinator.build(.home) + .navigationDestination(for: Screen.self) { screen in + navigationCoordinator.build(screen) + } + .sheet(item: $navigationCoordinator.sheet) { sheet in + navigationCoordinator.build(sheet) + } + .fullScreenCover(item: $navigationCoordinator.fullScreenCover) { fullScreenCover in + navigationCoordinator.build(fullScreenCover) + } + } + .environmentObject(navigationCoordinator) + .environmentObject(pdfFileManager) + .onAppear { + setSample() + } + } + } +} + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + // 전체 Tint Color 설정 + UIView.appearance().tintColor = UIColor.primary1 + + return true + } +} + + +extension AppView { + private func setSample() { + let isFirst = UserDefaults.standard.bool(forKey: "sample") + + if isFirst { + return + } + + let url = Bundle.main.url(forResource: "sample", withExtension: "json")! + + let layout = try! JSONDecoder().decode(PDFLayout.self, from: .init(contentsOf: url)) + + let id = pdfFileManager.uploadSampleFile()! + UserDefaults.standard.set(id.uuidString, forKey: "sampleId") + pdfFileManager.updateIsFigureSaved(at: id, isFigureSaved: true) + + layout.fig.forEach { + let _ = FigureDataService.shared.saveFigureData(for: id, with: .init( + id: $0.id, + head: $0.head, + label: $0.label, + figDesc: $0.figDesc, + coords: $0.coords, + graphicCoord: $0.graphicCoord)) + } + + UserDefaults.standard.set(true, forKey: "sample") + } +} diff --git a/Project/Reazy/Data/DTO/ButtonGroupData+CoreDataClass.swift b/Project/Reazy/Data/DTO/ButtonGroupData+CoreDataClass.swift new file mode 100644 index 00000000..366a82c8 --- /dev/null +++ b/Project/Reazy/Data/DTO/ButtonGroupData+CoreDataClass.swift @@ -0,0 +1,14 @@ +// +// ButtonGroupData+CoreDataClass.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData + +@objc(ButtonGroupData) +public class ButtonGroupData: NSManagedObject { + +} diff --git a/Project/Reazy/Data/DTO/ButtonGroupData+CoreDataProperties.swift b/Project/Reazy/Data/DTO/ButtonGroupData+CoreDataProperties.swift new file mode 100644 index 00000000..9c5d1497 --- /dev/null +++ b/Project/Reazy/Data/DTO/ButtonGroupData+CoreDataProperties.swift @@ -0,0 +1,23 @@ +// +// ButtonGroupData+CoreDataProperties.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData + +extension ButtonGroupData { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ButtonGroupData") + } + + @NSManaged public var id: UUID + @NSManaged public var page: Int32 + @NSManaged public var selectedLine: Data + @NSManaged public var buttonPosition: Data + + @NSManaged public var paperData: PaperData? +} diff --git a/Project/Reazy/Data/DTO/CommentData+CoreDataClass.swift b/Project/Reazy/Data/DTO/CommentData+CoreDataClass.swift new file mode 100644 index 00000000..0b85f2e4 --- /dev/null +++ b/Project/Reazy/Data/DTO/CommentData+CoreDataClass.swift @@ -0,0 +1,14 @@ +// +// CommentData+CoreDataClass.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData + +@objc(CommentData) +public class CommentData: NSManagedObject { + +} diff --git a/Project/Reazy/Data/DTO/CommentData+CoreDataProperties.swift b/Project/Reazy/Data/DTO/CommentData+CoreDataProperties.swift new file mode 100644 index 00000000..66936f33 --- /dev/null +++ b/Project/Reazy/Data/DTO/CommentData+CoreDataProperties.swift @@ -0,0 +1,26 @@ +// +// CommentData+CoreDataProperties.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData + +extension CommentData { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "CommentData") + } + + @NSManaged public var id: UUID + @NSManaged public var buttonID: UUID + @NSManaged public var text: String + @NSManaged public var selectedText: String + @NSManaged public var selectionByLine: Set + @NSManaged public var pages: [Int] + @NSManaged public var bounds: Data + + @NSManaged public var paperData: PaperData? +} diff --git a/Project/Reazy/Data/DTO/FigureData+CoreDataClass.swift b/Project/Reazy/Data/DTO/FigureData+CoreDataClass.swift new file mode 100644 index 00000000..dbb8deb3 --- /dev/null +++ b/Project/Reazy/Data/DTO/FigureData+CoreDataClass.swift @@ -0,0 +1,14 @@ +// +// FigureData+CoreDataClass.swift +// Reazy +// +// Created by 유지수 on 11/10/24. +// + +import Foundation +import CoreData + +@objc(FigureData) +public class FigureData: NSManagedObject { + +} diff --git a/Project/Reazy/Data/DTO/FigureData+CoreDataProperties.swift b/Project/Reazy/Data/DTO/FigureData+CoreDataProperties.swift new file mode 100644 index 00000000..12a2374b --- /dev/null +++ b/Project/Reazy/Data/DTO/FigureData+CoreDataProperties.swift @@ -0,0 +1,25 @@ +// +// FigureData+CoreDataProperties.swift +// Reazy +// +// Created by 유지수 on 11/10/24. +// + +import Foundation +import CoreData + +extension FigureData { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "FigureData") + } + + @NSManaged public var id: String + @NSManaged public var head: String? + @NSManaged public var label: String? + @NSManaged public var figDesc: String? + @NSManaged public var coords: [String] + @NSManaged public var graphicCoord: [String]? + + @NSManaged public var paperData: PaperData? +} diff --git a/Project/Reazy/Data/DTO/PaperData+CoreDataClass.swift b/Project/Reazy/Data/DTO/PaperData+CoreDataClass.swift new file mode 100644 index 00000000..0cea6e12 --- /dev/null +++ b/Project/Reazy/Data/DTO/PaperData+CoreDataClass.swift @@ -0,0 +1,14 @@ +// +// PaperData+CoreDataClass.swift +// Reazy +// +// Created by 유지수 on 11/6/24. +// + +import Foundation +import CoreData + +@objc(PaperData) +public class PaperData: NSManagedObject { + +} diff --git a/Project/Reazy/Data/DTO/PaperData+CoreDataProperties.swift b/Project/Reazy/Data/DTO/PaperData+CoreDataProperties.swift new file mode 100644 index 00000000..4abf2adb --- /dev/null +++ b/Project/Reazy/Data/DTO/PaperData+CoreDataProperties.swift @@ -0,0 +1,28 @@ +// +// PaperData+CoreDataProperties.swift +// Reazy +// +// Created by 유지수 on 11/6/24. +// + +import Foundation +import CoreData + +extension PaperData { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "PaperData") + } + + @NSManaged public var id: UUID + @NSManaged public var title: String + @NSManaged public var thumbnail: Data + @NSManaged public var url: Data + @NSManaged public var lastModifiedDate: Date + @NSManaged public var isFavorite: Bool + @NSManaged public var memo: String? + @NSManaged public var isFigureSaved: Bool + + @NSManaged public var figureData: Set? + @NSManaged public var commentData: Set? +} diff --git a/Project/Reazy/Data/DTO/SelectionByLine+CoreDataClass.swift b/Project/Reazy/Data/DTO/SelectionByLine+CoreDataClass.swift new file mode 100644 index 00000000..6c348191 --- /dev/null +++ b/Project/Reazy/Data/DTO/SelectionByLine+CoreDataClass.swift @@ -0,0 +1,14 @@ +// +// SelectionByLine+CoreDataClass.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData + +@objc(SelectionByLine) +public class SelectionByLine: NSManagedObject { + +} diff --git a/Project/Reazy/Data/DTO/SelectionByLine+CoreDataProperties.swift b/Project/Reazy/Data/DTO/SelectionByLine+CoreDataProperties.swift new file mode 100644 index 00000000..4e42f58b --- /dev/null +++ b/Project/Reazy/Data/DTO/SelectionByLine+CoreDataProperties.swift @@ -0,0 +1,21 @@ +// +// SelectionByLine+CoreDataProperties.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData + +extension SelectionByLine { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "SelectionByLine") + } + + @NSManaged public var page: Int32 + @NSManaged public var bounds: Data + + @NSManaged public var commentData: CommentData? +} diff --git a/Project/Reazy/Data/PersistantContainer.swift b/Project/Reazy/Data/PersistantContainer.swift new file mode 100644 index 00000000..77dbc03f --- /dev/null +++ b/Project/Reazy/Data/PersistantContainer.swift @@ -0,0 +1,29 @@ +// +// PersistantContainer.swift +// Reazy +// +// Created by 문인범 on 11/10/24. +// + +import CoreData + + +final class PersistantContainer { + static let shared = PersistantContainer() + + public var container: NSPersistentContainer { + self._container + } + + private let _container: NSPersistentContainer + + private init() { + self._container = .init(name: "Reazy") + self._container.loadPersistentStores { + (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + } +} diff --git a/Project/Reazy/Data/Reazy.xcdatamodeld/Reazy.xcdatamodel/contents b/Project/Reazy/Data/Reazy.xcdatamodeld/Reazy.xcdatamodel/contents new file mode 100644 index 00000000..7c887b79 --- /dev/null +++ b/Project/Reazy/Data/Reazy.xcdatamodeld/Reazy.xcdatamodel/contents @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Project/Reazy/Data/Services/ButtonGroupDataService.swift b/Project/Reazy/Data/Services/ButtonGroupDataService.swift new file mode 100644 index 00000000..5bb211ee --- /dev/null +++ b/Project/Reazy/Data/Services/ButtonGroupDataService.swift @@ -0,0 +1,102 @@ +// +// ButtonGroupDataService.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData +import UIKit + +class ButtonGroupDataService: ButtonGroupDataInterface { + static let shared = ButtonGroupDataService() + + private let container: NSPersistentContainer = PersistantContainer.shared.container + + private init() { } + + func loadButtonGroup(for pdfID: UUID) -> Result<[ButtonGroup], any Error> { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = ButtonGroupData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@", pdfID as CVarArg) + + do { + let fetchedGroups = try dataContext.fetch(fetchRequest) + let buttonGroups = fetchedGroups.map { buttonGroupData -> ButtonGroup in + + let selectedLine = convertDataToCGRect(buttonGroupData.selectedLine) + let buttonPosition = convertDataToCGRect(buttonGroupData.buttonPosition) + + return ButtonGroup( + id: buttonGroupData.id, + page: Int(buttonGroupData.page), + selectedLine: selectedLine, + buttonPosition: buttonPosition + ) + } + return .success(buttonGroups) + } catch { + return .failure(error) + } + } + + func saveButtonGroup(for pdfID: UUID, with buttonGroup: ButtonGroup) -> Result { + let dataContext = container.viewContext + let fetchedRequest: NSFetchRequest = PaperData.fetchRequest() + fetchedRequest.predicate = NSPredicate(format: "id == %@", pdfID as CVarArg) + + do { + if let paperData = try dataContext.fetch(fetchedRequest).first { + let newButtonGroup = ButtonGroupData(context: dataContext) + + newButtonGroup.id = buttonGroup.id + newButtonGroup.page = Int32(buttonGroup.page) + newButtonGroup.selectedLine = convertCGRectToData(buttonGroup.selectedLine) ?? Data() + newButtonGroup.buttonPosition = convertCGRectToData(buttonGroup.buttonPosition) ?? Data() + + newButtonGroup.paperData = paperData + + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "ButtonGroupData not found"])) + } + } catch { + return .failure(error) + } + } + + func deleteButtonGroup(for pdfID: UUID, id: UUID) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = ButtonGroupData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@ AND id == %@", pdfID as CVarArg, id as CVarArg) + + do { + let result = try dataContext.fetch(fetchRequest) + if let buttonGroupToDelete = result.first { + + dataContext.delete(buttonGroupToDelete) + + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "ButtonGroup not found"])) + } + } catch { + return .failure(error) + } + } + + private func convertCGRectToData(_ rect: CGRect) -> Data? { + return try? NSKeyedArchiver.archivedData(withRootObject: NSValue(cgRect: rect), requiringSecureCoding: true) + } + + private func convertDataToCGRect(_ data: Data?) -> CGRect { + guard let data = data, + let rectValue = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSValue.self, from: data) else { + return .zero + } + return rectValue.cgRectValue + } +} diff --git a/Project/Reazy/Data/Services/CommentDataService.swift b/Project/Reazy/Data/Services/CommentDataService.swift new file mode 100644 index 00000000..894be148 --- /dev/null +++ b/Project/Reazy/Data/Services/CommentDataService.swift @@ -0,0 +1,144 @@ +// +// CommentDataService.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation +import CoreData +import UIKit + +class CommentDataService: CommentDataInterface { + static let shared = CommentDataService() + + private let container: NSPersistentContainer = PersistantContainer.shared.container + + private init() { } + + func loadCommentData(for pdfID: UUID) -> Result<[Comment], Error> { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = CommentData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@", pdfID as CVarArg) + + do { + let fetchedComments = try dataContext.fetch(fetchRequest) + let comments = fetchedComments.map { commentData -> Comment in + + let selectionsByLine = commentData.selectionByLine.map { selection in + let bounds = convertDataToCGRect(selection.bounds) + + return selectionByLine(page: Int(selection.page), bounds: bounds) + } + + let bounds = convertDataToCGRect(commentData.bounds) + + return Comment( + id: commentData.id, + buttonId: commentData.buttonID, + text: commentData.text, + selectedText: commentData.selectedText, + selectionsByLine: selectionsByLine, + pages: commentData.pages, + bounds: bounds + ) + } + return .success(comments) + } catch { + return .failure(error) + } + } + + func saveCommentData(for pdfID: UUID, with comment: Comment) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = PaperData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", pdfID as CVarArg) + + do { + if let paperData = try dataContext.fetch(fetchRequest).first { + let newCommentData = CommentData(context: dataContext) + + newCommentData.id = comment.id + newCommentData.buttonID = comment.buttonId + newCommentData.text = comment.text + newCommentData.selectedText = comment.selectedText + + newCommentData.selectionByLine = Set(comment.selectionsByLine.map { selection in + let selectionData = SelectionByLine(context: dataContext) + selectionData.page = Int32(selection.page) + + let bounds = convertCGRectToData(selection.bounds) ?? Data() + selectionData.bounds = bounds + return selectionData + }) + + newCommentData.pages = comment.pages + + newCommentData.bounds = convertCGRectToData(comment.bounds) ?? Data() + + newCommentData.paperData = paperData + + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "CommentData not found"])) + } + } catch { + return .failure(error) + } + } + + func editCommentData(for pdfID: UUID, with comment: Comment) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = CommentData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@ AND id == %@", pdfID as CVarArg, comment.id as CVarArg) + + do { + let result = try dataContext.fetch(fetchRequest) + if let commentToEdit = result.first { + + commentToEdit.text = comment.text + + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Comment not found"])) + } + } catch { + return .failure(error) + } + } + + func deleteCommentData(for pdfID: UUID, id: UUID) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = CommentData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@ AND id == %@", pdfID as CVarArg, id as CVarArg) + + do { + let result = try dataContext.fetch(fetchRequest) + if let commentToDelete = result.first { + + dataContext.delete(commentToDelete) + + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Comment not found"])) + } + } catch { + return .failure(error) + } + } + + private func convertCGRectToData(_ rect: CGRect) -> Data? { + return try? NSKeyedArchiver.archivedData(withRootObject: NSValue(cgRect: rect), requiringSecureCoding: true) + } + + private func convertDataToCGRect(_ data: Data?) -> CGRect { + guard let data = data, + let rectValue = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSValue.self, from: data) else { + return .zero + } + return rectValue.cgRectValue + } +} diff --git a/Project/Reazy/Data/Services/FigureDataService.swift b/Project/Reazy/Data/Services/FigureDataService.swift new file mode 100644 index 00000000..2bbd7087 --- /dev/null +++ b/Project/Reazy/Data/Services/FigureDataService.swift @@ -0,0 +1,134 @@ +// +// FigureDataService.swift +// Reazy +// +// Created by 유지수 on 11/10/24. +// + +import Foundation +import CoreData +import UIKit + +class FigureDataService: FigureDataInterface { + static let shared = FigureDataService() + + private let container: NSPersistentContainer = PersistantContainer.shared.container + + private init() { } + + func loadFigureData(for pdfID: UUID) -> Result<[Figure], any Error> { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = FigureData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@", pdfID as CVarArg) + + do { + let fetchedFigures = try dataContext.fetch(fetchRequest) + let figures = fetchedFigures.map { figureData -> Figure in + + return Figure( + id: figureData.id, + head: figureData.head, + label: figureData.label, + figDesc: figureData.figDesc, + coords: figureData.coords, + graphicCoord: figureData.graphicCoord + ) + } + return .success(figures) + } catch { + return .failure(error) + } + } + + func saveFigureData(for pdfID: UUID, with figure: Figure) -> Result { + var result: Result? + + /// NSManagedObject는 Thread-safe 하지 못해 하나의 쓰레드에서만 사용해야 함 + /// 해결 방법으로 performBackgroundTask 사용 + container.performBackgroundTask { context in + // 저장되어 있는 것을 우선으로 하는 merge policy + context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) + + let fetchRequest: NSFetchRequest = PaperData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", pdfID as CVarArg) + + do { + if let paperData = try context.fetch(fetchRequest).first { + + let newFigureData = FigureData(context: context) + + newFigureData.id = figure.id + newFigureData.head = figure.head + newFigureData.label = figure.label + newFigureData.figDesc = figure.figDesc + newFigureData.coords = figure.coords + newFigureData.graphicCoord = figure.graphicCoord + + newFigureData.paperData = paperData + + do { + try context.save() + result = .success(.init()) + } catch { + print(String(describing: error)) + result = .failure(error) + } + } else { + result = .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "FigureData not found"])) + } + } catch { + result = .failure(error) + } + } + + if result == nil { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "FigureData not found"])) + } + + return result! + } + + func editFigureData(for pdfID: UUID, with figure: Figure) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = FigureData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@ AND id == %@", pdfID as CVarArg, figure.id as CVarArg) + + do { + let result = try dataContext.fetch(fetchRequest) + if let figureToEdit = result.first { + + figureToEdit.head = figure.head + figureToEdit.label = figure.label + figureToEdit.figDesc = figure.figDesc + figureToEdit.coords = figure.coords + figureToEdit.graphicCoord = figure.graphicCoord + + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Figure not found"])) + } + } catch { + return .failure(error) + } + } + + func deleteFigureData(for pdfID: UUID, id: String) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = FigureData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "paperData.id == %@ AND id == %@", pdfID as CVarArg, id as CVarArg) + + do { + let result = try dataContext.fetch(fetchRequest) + if let figureToDelete = result.first { + dataContext.delete(figureToDelete) + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Figure not found"])) + } + } catch { + return .failure(error) + } + } +} diff --git a/Project/Reazy/Data/Services/PaperDataService.swift b/Project/Reazy/Data/Services/PaperDataService.swift new file mode 100644 index 00000000..1ed23ca4 --- /dev/null +++ b/Project/Reazy/Data/Services/PaperDataService.swift @@ -0,0 +1,116 @@ +// +// PaperDataService.swift +// Reazy +// +// Created by 유지수 on 11/6/24. +// + +import Foundation +import CoreData +import UIKit + +class PaperDataService: PaperDataInterface { + static let shared = PaperDataService() + + private let container: NSPersistentContainer = PersistantContainer.shared.container + + private init() { } + + // 저장된 PDF 정보를 모두 불러옵니다 + func loadPDFInfo() -> Result<[PaperInfo], any Error> { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = PaperData.fetchRequest() + + do { + let fetchedDataList = try dataContext.fetch(fetchRequest) + let pdfDataList = fetchedDataList.map { paperData -> PaperInfo in + // TODO: - URL 타입 수정 + + return PaperInfo( + id: paperData.id, + title: paperData.title, + thumbnail: paperData.thumbnail, + url: paperData.url, + lastModifiedDate: paperData.lastModifiedDate, + isFavorite: paperData.isFavorite, + memo: paperData.memo ?? nil, + isFigureSaved: paperData.isFigureSaved + ) + } + return .success(pdfDataList) + } catch { + return .failure(error) + } + } + + // 새로운 PDF를 저장합니다 + func savePDFInfo(_ info: PaperInfo) -> Result { + let dataContext = container.viewContext + let newPaperData = PaperData(context: dataContext) + + newPaperData.id = info.id + newPaperData.title = info.title + newPaperData.url = info.url + newPaperData.thumbnail = info.thumbnail + newPaperData.lastModifiedDate = info.lastModifiedDate + newPaperData.isFavorite = info.isFavorite + newPaperData.memo = info.memo + newPaperData.isFigureSaved = info.isFigureSaved + + do { + try dataContext.save() + return .success(VoidResponse()) + } catch { + return .failure(error) + } + } + + // 기존 PDF 정보를 수정합니다 + func editPDFInfo(_ info: PaperInfo) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = PaperData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", info.id as CVarArg) + + do { + let results = try dataContext.fetch(fetchRequest) + if let dataToEdit = results.first { + // 기존 데이터 수정 + dataToEdit.title = info.title + dataToEdit.url = info.url + dataToEdit.lastModifiedDate = info.lastModifiedDate + dataToEdit.isFavorite = info.isFavorite + dataToEdit.memo = info.memo + dataToEdit.isFigureSaved = info.isFigureSaved + + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Data not found"])) + } + } catch { + return .failure(error) + } + } + + // PDF 정보를 삭제합니다 + func deletePDFInfo(id: UUID) -> Result { + let dataContext = container.viewContext + let fetchRequest: NSFetchRequest = PaperData.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg) + + do { + let results = try dataContext.fetch(fetchRequest) + if let dataToDelete = results.first { + dataContext.delete(dataToDelete) + try dataContext.save() + return .success(VoidResponse()) + } else { + return .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Data not found"])) + } + } catch { + return .failure(error) + } + } + + +} diff --git a/Project/Reazy/DesignSystem/Extensions/Array.swift b/Project/Reazy/DesignSystem/Extensions/Array.swift new file mode 100644 index 00000000..b1c734bc --- /dev/null +++ b/Project/Reazy/DesignSystem/Extensions/Array.swift @@ -0,0 +1,18 @@ +// +// Array.swift +// Reazy +// +// Created by 유지수 on 10/14/24. +// + +import SwiftUI + +extension Array where Element == Bool { + + // isSelected Bool 배열 중 한 가지만 true + mutating func toggleSelection(at index: Int) { + for i in 0..> 16) & 0xFF) / 255.0 + let g = Double((rgb >> 8) & 0xFF) / 255.0 + let b = Double((rgb >> 0) & 0xFF) / 255.0 + self.init(red: r, green: g, blue: b) + } +} + +extension UIColor { + convenience init(hex: String) { + let scanner = Scanner(string: hex) + _ = scanner.scanString("#") + + var rgb: UInt64 = 0 + scanner.scanHexInt64(&rgb) + + let r = Double((rgb >> 16) & 0xFF) / 255.0 + let g = Double((rgb >> 8) & 0xFF) / 255.0 + let b = Double((rgb >> 0) & 0xFF) / 255.0 + + self.init(red: r, green: g, blue: b, alpha: 1) + } +} + diff --git a/Project/Reazy/DesignSystem/Extensions/View+NavigationBar.swift b/Project/Reazy/DesignSystem/Extensions/View+NavigationBar.swift new file mode 100644 index 00000000..99ae2896 --- /dev/null +++ b/Project/Reazy/DesignSystem/Extensions/View+NavigationBar.swift @@ -0,0 +1,89 @@ +// +// View+NavigationBar.swift +// Reazy +// +// Created by 유지수 on 10/15/24. +// + +import SwiftUI + +// MARK: - 커스텀 Navigation Bar +struct CustomNavigationBarModifier: ViewModifier where C : View, L : View, R : View { + let centerView: (() -> C)? + let leftView: (() -> L)? + let rightView: (() -> R)? + + init(centerView: (() -> C)? = nil, leftView: (() -> L)? = nil, rightView: (() -> R)? = nil) { + self.centerView = centerView + self.leftView = leftView + self.rightView = rightView + } + + func body(content: Content) -> some View { + VStack(spacing: 0) { + ZStack { + HStack { + self.leftView?() + Spacer() + self.rightView?() + } + .frame(height: 51) + .padding(.horizontal, 20) + + HStack { + Spacer() + self.centerView?() + Spacer() + } + } + .padding(.top, 10) + .background(.primary2) + + content + } + .navigationBarHidden(true) + } +} + +private struct WillDisappearModifier: ViewModifier { + let callback: () -> Void + + func body(content: Content) -> some View { + content + .onDisappear { + callback() + } + } +} + + +extension View { + func onWillDisappear(_ perform: @escaping () -> Void) -> some View { + self.modifier(WillDisappearModifier(callback: perform)) + } + + func customNavigationBar ( + centerView: @escaping (() -> C), + leftView: @escaping (() -> L), + rightView: @escaping (() -> R) + ) -> some View where C: View, L: View, R: View { + modifier( + CustomNavigationBarModifier(centerView: centerView, leftView: leftView, rightView: rightView) + ) + } + + func customNavigationBar ( + centerView: @escaping (() -> V) + ) -> some View where V: View { + modifier( + CustomNavigationBarModifier( + centerView: centerView, + leftView: { + EmptyView() + }, rightView: { + EmptyView() + } + ) + ) + } +} diff --git a/Project/Reazy/DesignSystem/Extensions/View+ReazyFont.swift b/Project/Reazy/DesignSystem/Extensions/View+ReazyFont.swift new file mode 100644 index 00000000..4c7ea993 --- /dev/null +++ b/Project/Reazy/DesignSystem/Extensions/View+ReazyFont.swift @@ -0,0 +1,102 @@ +// +// View+ReazyFont.swift +// Reazy +// +// Created by 유지수 on 10/15/24. +// + +import SwiftUI +import UIKit + +public enum ReazyFontType { + case h1 + case h2 + case h3 + case h4 + case h5 + + case button1 + case button2 + case button3 + case button4 + case button5 + + case text1 + case text2 + case text3 + case text4 + case text5 + + case body1 + case body2 + case body3 + + static let pretendardBoldFont: String = "Pretendard-Bold" + static let pretendardMediumFont: String = "Pretendard-Medium" + static let pretendardRegularFont: String = "Pretendard-Regular" + static let pretendardSemiboldFont: String = "Pretendard-SemiBold" + + + var fontSize: CGFloat { + switch self { + case .text4: return 10 + case .button4, .button5, .text2, .text5, .body3: return 12 + case .h3, .h4, .button2, .button3, .body1, .body2: return 14 + case .h2, .text3: return 15 + case .text1, .button1: return 16 + case .h5: return 20 + case .h1: return 24 + } + } + + var lineHeight: CGFloat { + switch self { + case .text4: return 12 + case .button4, .button5, .text5: return 14 + case .text2: return 16 + case .h3, .button3: return 17 + case .h4, .body3: return 18 + case .h2, .button2, .text1, .text3, .body1, .body2: return 20 + case .button1: return 23 + case .h1, .h5: return 34 + } + } + + var fontWeight: String { + switch self { + case .h1, .h5, .button1, .button2: return ReazyFontType.pretendardSemiboldFont + case .h2, .h3, .button5, .text1, .body1, .body3: return ReazyFontType.pretendardMediumFont + case .h4, .button3, .text2, .text4, .text5, .body2: return ReazyFontType.pretendardRegularFont + case .button4, .text3: return ReazyFontType.pretendardBoldFont + } + } +} + +extension View { + func reazyFont(_ type: ReazyFontType) -> some View { + let font = UIFont(name: type.fontWeight, size: type.fontSize) ?? UIFont.systemFont(ofSize: type.fontSize) + + return self + .font(Font(font)) + .lineSpacing(type.lineHeight - font.lineHeight) + .padding(.vertical, (type.lineHeight - font.lineHeight) / 2) + } +} + +/// UIKit 용 +extension UIFont { + static func reazyFont(_ type: ReazyFontType) -> UIFont { + return UIFont(name: type.fontWeight, size: type.fontSize) ?? .systemFont(ofSize: type.fontSize) + } + + static func reazyManualFont(_ type: ReazyUIFontType, size: CGFloat) -> UIFont { + UIFont(name: type.rawValue , size: size) ?? .systemFont(ofSize: size) + } + + enum ReazyUIFontType: String { + case bold = "Pretendard-Bold" + case medium = "Pretendard-Medium" + case regular = "Pretendard-Regular" + case semibold = "Pretendard-SemiBold" + } +} diff --git a/Project/Reazy/DesignSystem/Views/Buttons.swift b/Project/Reazy/DesignSystem/Views/Buttons.swift new file mode 100644 index 00000000..86867b9d --- /dev/null +++ b/Project/Reazy/DesignSystem/Views/Buttons.swift @@ -0,0 +1,126 @@ +// +// Buttons.swift +// Reazy +// +// Created by 유지수 on 10/24/24. +// + +import SwiftUI + +enum WriteButton: String, CaseIterable { + case comment + case highlight + case pencil + case eraser + case translate + + var icon: Image { + switch self { + case .comment: + return Image(systemName: "text.bubble") + case .highlight: + return Image("Highlight") + .renderingMode(.template) + case .pencil: + return Image("Pencil") + .renderingMode(.template) + case .eraser: + return Image(systemName: "eraser") + case .translate: + return Image(systemName: "globe") + } + } +} + +enum HighlightColors: String, CaseIterable { + case yellow + case pink + case green + case blue + + var color: Color { + switch self { + case .yellow: + return .highlight1 + case .pink: + return .highlight2 + case .green: + return .highlight3 + case .blue: + return .highlight4 + } + } + + var uiColor: UIColor { + return UIColor(self.color) + } +} + +struct ColorButton: View { + @Binding var button: HighlightColors + + let buttonOwner: HighlightColors + let action: () -> Void + + var body: some View { + Button(action: { + action() + }) { + ZStack { + Circle() + .fill(Color.clear) + .frame(width: 26, height: 26) + + Circle() + .frame(width: 18, height: 18) + .foregroundStyle(buttonOwner.color) + .overlay( + Image(systemName: "checkmark.circle") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundStyle(buttonOwner == button ? .gray700 : .clear) + ) + } + } + } +} + +struct WriteViewButton: View { + @Binding var button: WriteButton? + @Binding var HighlightColors: HighlightColors + + let buttonOwner: WriteButton + let action: () -> Void + + var body: some View { + let foregroundColor: Color = { + if buttonOwner == .highlight { + return button == .highlight ? HighlightColors.color : .gray800 + } else { + return button == buttonOwner ? .gray100 : .gray800 + } + }() + + Button(action: { + action() + }) { + RoundedRectangle(cornerRadius: 6) + .frame(width: 26, height: 26) + .foregroundStyle(button == buttonOwner ? .primary1 : .clear) + .overlay( + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .frame(width: 26, height: 26) + + buttonOwner.icon + .resizable() + .scaledToFit() + .foregroundStyle(foregroundColor) + .frame(height: 18) + } + ) + } + } +} diff --git a/Project/Reazy/DesignSystem/Views/SearchBar.swift b/Project/Reazy/DesignSystem/Views/SearchBar.swift new file mode 100644 index 00000000..0e71a93a --- /dev/null +++ b/Project/Reazy/DesignSystem/Views/SearchBar.swift @@ -0,0 +1,45 @@ +// +// SearchBar.swift +// Reazy +// +// Created by 유지수 on 10/17/24. +// + +import SwiftUI + +struct SearchBar: View { + + @Binding var text: String + + var body: some View { + HStack { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.gray600) + + TextField("검색", text: $text) + .foregroundStyle(.gray600) + + if !text.isEmpty { + Button(action: { + self.text = "" + }, label: { + Image(systemName: "xmark.circle.fill") + + }) + } else { + EmptyView() + } + } + .padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8)) + .foregroundStyle(.secondary) + .background(.primary2) + .cornerRadius(10.0) + } + .padding(.horizontal) + } +} + +#Preview { + SearchBar(text: .constant("")) +} diff --git a/Project/Reazy/Domain/Helpers/Navigation/CoordinatorProtocol.swift b/Project/Reazy/Domain/Helpers/Navigation/CoordinatorProtocol.swift new file mode 100644 index 00000000..081d34c7 --- /dev/null +++ b/Project/Reazy/Domain/Helpers/Navigation/CoordinatorProtocol.swift @@ -0,0 +1,29 @@ +// +// CoordinatorProtocol.swift +// Reazy +// +// Created by 문인범 on 11/5/24. +// + +import SwiftUI + + +/** + NavigationCoordinator 패턴 프로토콜 + */ +protocol CoordinatorProtocol: ObservableObject { + var path: NavigationPath { get set } + var sheet: Sheet? { get set } + var fullScreenCover: FullScreenCover? { get set } + + func push(_ screen: Screen) + func presentSheet(_ sheet: Sheet) + func presentFullScreenCover(_ fullScreenCover: FullScreenCover) + + func pop() + func popToRoot() + + func dismissSheet() + func dismissFullScreenCover() + +} diff --git a/Project/Reazy/Domain/Helpers/Navigation/NavigationCoordinator.swift b/Project/Reazy/Domain/Helpers/Navigation/NavigationCoordinator.swift new file mode 100644 index 00000000..ee6b8834 --- /dev/null +++ b/Project/Reazy/Domain/Helpers/Navigation/NavigationCoordinator.swift @@ -0,0 +1,83 @@ +// +// NavigationCoordinator.swift +// Reazy +// +// Created by 문인범 on 11/5/24. +// + +import SwiftUI + + +/** + Navigation 관리하는 클래스 + */ +final class NavigationCoordinator: CoordinatorProtocol { + @Published public var path: NavigationPath = .init() + @Published var sheet: Sheet? + @Published var fullScreenCover: FullScreenCover? + + func push(_ screen: Screen) { + path.append(screen) + } + + func presentSheet(_ sheet: Sheet) { + self.sheet = sheet + } + + func presentFullScreenCover(_ fullScreenCover: FullScreenCover) { + self.fullScreenCover = fullScreenCover + } + + func pop() { + if path.count > 0 { + path.removeLast() + } + } + + func popToRoot() { + path.removeLast(path.count) + } + + func dismissSheet() { + self.sheet = nil + } + + func dismissFullScreenCover() { + self.fullScreenCover = nil + } + + @ViewBuilder + func build(_ screen: Screen) -> some View { + switch screen { + case .home: + HomeView() + case .mainPDF(let paperInfo): + MainPDFView( + mainPDFViewModel: .init(paperInfo: paperInfo), + commentViewModel: .init( + paperInfo: paperInfo, + commentService: CommentDataService.shared, + buttonGroupService: ButtonGroupDataService.shared + ) + ) + } + } + + @ViewBuilder + func build(_ sheet: Sheet) -> some View { + switch sheet { + case .none: + EmptyView() + } + } + + @ViewBuilder + func build(_ fullScreenCover: FullScreenCover) -> some View { + switch fullScreenCover { + case .none: + EmptyView() + } + } + +} + diff --git a/Project/Reazy/Domain/Helpers/Navigation/PresentationType.swift b/Project/Reazy/Domain/Helpers/Navigation/PresentationType.swift new file mode 100644 index 00000000..a5f956c0 --- /dev/null +++ b/Project/Reazy/Domain/Helpers/Navigation/PresentationType.swift @@ -0,0 +1,76 @@ +// +// PresentationType.swift +// Reazy +// +// Created by 문인범 on 11/5/24. +// + +import Foundation + +/** + 네비게이션에 사용되는 열거형 입니다. 추가해야되는 뷰가 있을 경우 해당 열거형을 수정하면 됩니다. + Screen: Navigation에 사용되는 열거형입니다. + Sheet: 바텀 시트에 사용되는 열거형입니다. + FullScreenCover: 화면을 덮는 시트에 사용되는 열거형 입니다. + */ + + + +enum Screen: Identifiable, Hashable { + case home + case mainPDF(paperInfo: PaperInfo) + + var id: Self { self } +} + +enum Sheet: Identifiable, Hashable { + case none + + var id: Self { self } +} + +enum FullScreenCover: Identifiable, Hashable { + + case none(test: () -> Void) + + var id: Self { self } +} + + + + +// MARK: - 열거형 프로토콜 충족 +extension Screen { + func hash(into hasher: inout Hasher) { + switch self { + case .home: + hasher.combine("home") + case .mainPDF: + hasher.combine("mainPDF") + } + } + + static func == (lhs: Screen, rhs: Screen) -> Bool { + switch (lhs, rhs) { + default: + return true + } + } +} + + +extension FullScreenCover { + func hash(into hasher: inout Hasher) { + switch self { + case .none: + hasher.combine("none") + } + } + + static func == (lhs: FullScreenCover, rhs: FullScreenCover) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + } + } +} diff --git a/Project/Reazy/Domain/Helpers/NotificationCenter + Extension.swift b/Project/Reazy/Domain/Helpers/NotificationCenter + Extension.swift new file mode 100644 index 00000000..ea68b6b2 --- /dev/null +++ b/Project/Reazy/Domain/Helpers/NotificationCenter + Extension.swift @@ -0,0 +1,18 @@ +// +// NotificationCenter + Extension.swift +// Reazy +// +// Created by 문인범 on 10/20/24. +// + +import Foundation + + +/** + Notification Center 이름 등록 + */ +extension Notification.Name { + static let didSelectThumbnail = Notification.Name("didSelectThumbnail") + static let isSearchViewHidden = Notification.Name("isSearchViewHidden") + static let isCommentTapped = Notification.Name("isCommentTapped") +} diff --git a/Project/Reazy/Domain/Helpers/PDFFileManager.swift b/Project/Reazy/Domain/Helpers/PDFFileManager.swift new file mode 100644 index 00000000..679988d9 --- /dev/null +++ b/Project/Reazy/Domain/Helpers/PDFFileManager.swift @@ -0,0 +1,227 @@ +// +// FileUploadManager.swift +// Reazy +// +// Created by 문인범 on 11/5/24. +// + +import Foundation +import PDFKit +import SwiftUICore + + +/** + 홈 뷰 및 pdf 업로드 관할 매니저 + */ +final class PDFFileManager: ObservableObject { + @Published public var paperInfos: [PaperInfo] = [] + @Published public var isLoading: Bool = false + @Published public var memoText: String = "" + + private var paperService: PaperDataService + + + init( + paperService: PaperDataService + ) { + self.paperService = paperService + // MARK: - 기존에 저장된 데이터가 있다면 모델에 저장된 데이터를 추가 + switch paperService.loadPDFInfo() { + case .success(let paperList): + paperInfos = paperList.sorted(by: { $0.lastModifiedDate > $1.lastModifiedDate }) + case .failure(_): + return + } + } +} + +/// pdf 업로드 관련 메소드 +extension PDFFileManager { + @MainActor + public func uploadPDFFile(url: [URL]) throws -> UUID? { + self.isLoading = true + + guard let url = url.first else { return UUID() } + + guard url.startAccessingSecurityScopedResource() else { + throw PDFUploadError.failedToAccessingSecurityScope + } + + defer { + self.isLoading = false + url.stopAccessingSecurityScopedResource() + } + + let tempDoc = PDFDocument(url: url) + var lastComponent = url.lastPathComponent.split(separator: ".") + lastComponent.removeLast() + + let title = lastComponent.joined() + + guard let urlData = try? url.bookmarkData(options: .minimalBookmark) else { + throw PDFUploadError.invalidURL + } + + if let firstPage = tempDoc?.page(at: 0) { + let width = firstPage.bounds(for: .mediaBox).width + let height = firstPage.bounds(for: .mediaBox).height + + let image = firstPage.thumbnail(of: .init(width: width, height: height), for: .mediaBox) + let thumbnailData = image.pngData() + + let paperInfo = PaperInfo( + title: title, + thumbnail: thumbnailData!, + url: urlData + ) + + _ = paperService.savePDFInfo(paperInfo) + paperInfos.append(paperInfo) + + return paperInfo.id + + } else { + let paperInfo = PaperInfo( + title: title, + thumbnail: UIImage(resource: .testThumbnail).pngData()!, + url: urlData + ) + + _ = paperService.savePDFInfo(paperInfo) + paperInfos.append(paperInfo) + + return paperInfo.id + } + } + + @MainActor + public func uploadSampleFile() -> UUID? { + let pdfURL = Bundle.main.url(forResource: "Reazy Sample Paper", withExtension: "pdf")! + + let tempDoc = PDFDocument(url: pdfURL) + var lastComponent = pdfURL.lastPathComponent.split(separator: ".") + lastComponent.removeLast() + + let title = lastComponent.joined() + let sampleData = Data() + + if let firstPage = tempDoc?.page(at: 0) { + let width = firstPage.bounds(for: .mediaBox).width + let height = firstPage.bounds(for: .mediaBox).height + + let image = firstPage.thumbnail(of: .init(width: width, height: height), for: .mediaBox) + let thumbnailData = image.pngData() + + let paperInfo = PaperInfo( + title: title, + thumbnail: thumbnailData!, + url: sampleData + ) + + _ = paperService.savePDFInfo(paperInfo) + paperInfos.append(paperInfo) + + return paperInfo.id + + } else { + let paperInfo = PaperInfo( + title: title, + thumbnail: UIImage(resource: .testThumbnail).pngData()!, + url: sampleData + ) + + _ = paperService.savePDFInfo(paperInfo) + paperInfos.append(paperInfo) + + return paperInfo.id + } + + + } + + public func deletePDFFile(at id: UUID) { + _ = paperService.deletePDFInfo(id: id) + paperInfos.removeAll(where: { $0.id == id }) + } + + public func deletePDFFiles(at ids: [UUID]) { + ids.forEach { id in + _ = paperService.deletePDFInfo(id: id) + } + paperInfos.removeAll { ids.contains($0.id) } + } + + public func updateTitle(at id: UUID, title: String) { + if let index = paperInfos.firstIndex(where: { $0.id == id }) { + paperInfos[index].title = title + _ = paperService.editPDFInfo(paperInfos[index]) + } + } + + public func updateMemo(at id: UUID, memo: String) { + if let index = paperInfos.firstIndex(where: { $0.id == id }) { + paperInfos[index].memo = memo + _ = paperService.editPDFInfo(paperInfos[index]) + } + } + + public func deleteMemo(at id: UUID) { + if let index = paperInfos.firstIndex(where: { $0.id == id }) { + paperInfos[index].memo = nil + _ = paperService.editPDFInfo(paperInfos[index]) + } + } + + public func updateFavorite(at id: UUID, isFavorite: Bool) { + if let index = paperInfos.firstIndex(where: { $0.id == id }) { + paperInfos[index].isFavorite = isFavorite + _ = paperService.editPDFInfo(paperInfos[index]) + } + } + + public func updateFavorites(at ids: [UUID]) { + ids.forEach { id in + if let index = paperInfos.firstIndex(where: { $0.id == id }) { + paperInfos[index].isFavorite = true + _ = paperService.editPDFInfo(paperInfos[index]) + } + } + } + + public func updateLastModifiedDate(at id: UUID, lastModifiedDate: Date) { + if let index = paperInfos.firstIndex(where: { $0.id == id }) { + paperInfos[index].lastModifiedDate = lastModifiedDate + _ = paperService.editPDFInfo(paperInfos[index]) + } + } + + public func updateIsFigureSaved(at id: UUID, isFigureSaved: Bool) { + if let index = paperInfos.firstIndex(where: { $0.id == id }) { + paperInfos[index].isFigureSaved = isFigureSaved + _ = paperService.editPDFInfo(paperInfos[index]) + } + } +} + + +// MARK: Sample 메소드 +extension PDFFileManager { + @MainActor + public func uploadSampleData() { + let sampleUrl = Bundle.main.url(forResource: "engPD5", withExtension: "pdf")! + self.paperInfos.append(PaperInfo( + title: "A review of the global climate change impacts, adaptation, and sustainable mitigation measures", + thumbnail: .init(), + url: try! Data(contentsOf: sampleUrl), + isFavorite: false, + memo: "test", + isFigureSaved: false + )) + } +} + +enum PDFUploadError: Error { + case failedToAccessingSecurityScope + case invalidURL + +} diff --git a/Project/Reazy/Domain/Interface/ButtonGroupDataInterface.swift b/Project/Reazy/Domain/Interface/ButtonGroupDataInterface.swift new file mode 100644 index 00000000..3b52c397 --- /dev/null +++ b/Project/Reazy/Domain/Interface/ButtonGroupDataInterface.swift @@ -0,0 +1,17 @@ +// +// ButtonGroupDataInterface.swift +// Reazy +// +// Created by 유지수 on 11/11/24. +// + +import Foundation + +protocol ButtonGroupDataInterface { + /// 저장된 버튼 그룹을 불러옵니다 + func loadButtonGroup(for pdfID: UUID) -> Result<[ButtonGroup], Error> + /// 버튼 그룹을 저장합니다 + func saveButtonGroup(for pdfID: UUID, with buttonGroup: ButtonGroup) -> Result + /// 버튼 그룹을 삭제합니다 + func deleteButtonGroup(for pdfID: UUID, id: UUID) -> Result +} diff --git a/Project/Reazy/Domain/Interface/CommentDataInterface.swift b/Project/Reazy/Domain/Interface/CommentDataInterface.swift new file mode 100644 index 00000000..24531fa1 --- /dev/null +++ b/Project/Reazy/Domain/Interface/CommentDataInterface.swift @@ -0,0 +1,19 @@ +// +// CommentDataInterface.swift +// Reazy +// +// Created by 유지수 on 11/7/24. +// + +import Foundation + +protocol CommentDataInterface { + /// 코멘트 기록을 불러옵니다 + func loadCommentData(for pdfID: UUID) -> Result<[Comment], Error> + /// 코멘트 기록을 저장합니다 + func saveCommentData(for pdfID: UUID, with comment: Comment) -> Result + /// 코멘트 기록을 수정합니다 + func editCommentData(for pdfID: UUID, with comment: Comment) -> Result + /// 코멘트 기록을 삭제합니다 + func deleteCommentData(for pdfID: UUID, id: UUID) -> Result +} diff --git a/Project/Reazy/Domain/Interface/FigureDataInterface.swift b/Project/Reazy/Domain/Interface/FigureDataInterface.swift new file mode 100644 index 00000000..c6a5ec5e --- /dev/null +++ b/Project/Reazy/Domain/Interface/FigureDataInterface.swift @@ -0,0 +1,19 @@ +// +// FigureDataInterface.swift +// Reazy +// +// Created by 유지수 on 11/10/24. +// + +import Foundation + +protocol FigureDataInterface { + /// 저장된 FigureData를 불러옵니다 + func loadFigureData(for pdfID: UUID) -> Result<[Figure], Error> + /// FigureData를 저장합니다 + func saveFigureData(for pdfID: UUID, with figure: Figure) -> Result + /// FigureData를 수정합니다 + func editFigureData(for pdfID: UUID, with figure: Figure) -> Result + /// FigureData를 삭제합니다 + func deleteFigureData(for pdfID: UUID, id: String) -> Result +} diff --git a/Project/Reazy/Domain/Interface/PaperDataInterface.swift b/Project/Reazy/Domain/Interface/PaperDataInterface.swift new file mode 100644 index 00000000..fb9e1125 --- /dev/null +++ b/Project/Reazy/Domain/Interface/PaperDataInterface.swift @@ -0,0 +1,19 @@ +// +// PaperDataInterface.swift +// Reazy +// +// Created by 유지수 on 11/6/24. +// + +import Foundation + +protocol PaperDataInterface { + /// 저장된 PDF 정보를 모두 불러옵니다 + func loadPDFInfo() -> Result<[PaperInfo], Error> + /// 새로운 PDF를 저장합니다 + func savePDFInfo(_ info: PaperInfo) -> Result + /// 기존 PDF 정보를 수정합니다 + func editPDFInfo(_ info: PaperInfo) -> Result + /// PDF 정보를 삭제합니다 + func deletePDFInfo(id: UUID) -> Result +} diff --git a/Project/Reazy/Domain/Models/Comment.swift b/Project/Reazy/Domain/Models/Comment.swift new file mode 100644 index 00000000..c593caa7 --- /dev/null +++ b/Project/Reazy/Domain/Models/Comment.swift @@ -0,0 +1,31 @@ +// +// Comment.swift +// Reazy +// +// Created by 김예림 on 10/24/24. +// + +import Foundation +import PDFKit + +struct Comment: Identifiable { + let id : UUID + let buttonId: UUID // commentIcon + var text: String // 입력한 텍스트 + var selectedText: String // selection 텍스트 + var selectionsByLine: [selectionByLine] // 하이라이트 + var pages: [Int] // selection page 배열 + var bounds: CGRect // selection 전체영역 +} + +struct selectionByLine { + var page: Int + var bounds: CGRect +} + +struct ButtonGroup: Identifiable { + let id : UUID + var page: Int + var selectedLine: CGRect + var buttonPosition: CGRect +} diff --git a/Project/Reazy/Domain/Models/DrawingData.swift b/Project/Reazy/Domain/Models/DrawingData.swift new file mode 100644 index 00000000..6cc908d9 --- /dev/null +++ b/Project/Reazy/Domain/Models/DrawingData.swift @@ -0,0 +1,15 @@ +// +// DrawingData.swift +// Reazy +// +// Created by Minjung Lee on 11/5/24. +// + +import UIKit + +struct Drawing { + let id: UUID // 드로잉 고유 ID + var pageIndex: Int // 페이지 + var path: UIBezierPath // 이동 경로 + var color: UIColor // 색상 +} diff --git a/Project/Reazy/Domain/Models/FigureAnnotation.swift b/Project/Reazy/Domain/Models/FigureAnnotation.swift new file mode 100644 index 00000000..bf376c0d --- /dev/null +++ b/Project/Reazy/Domain/Models/FigureAnnotation.swift @@ -0,0 +1,16 @@ +// +// FigureAnnotation.swift +// Reazy +// +// Created by 조성호 on 10/19/24. +// + +import Foundation + +// 이미지 위치 파악용 모델 +struct FigureAnnotation { + let page: Int + let head: String + let position: CGRect +} + diff --git a/Project/Reazy/Domain/Models/FocusAnnotation.swift b/Project/Reazy/Domain/Models/FocusAnnotation.swift new file mode 100644 index 00000000..100a987a --- /dev/null +++ b/Project/Reazy/Domain/Models/FocusAnnotation.swift @@ -0,0 +1,15 @@ +// +// FocusAnnotation.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import Foundation + +/// 집중모드 위치 파악용 모델 +struct FocusAnnotation { + let page: Int + let header: String + let position: CGRect +} diff --git a/Project/Reazy/Domain/Models/PDFInfo.swift b/Project/Reazy/Domain/Models/PDFInfo.swift new file mode 100644 index 00000000..791dbb26 --- /dev/null +++ b/Project/Reazy/Domain/Models/PDFInfo.swift @@ -0,0 +1,27 @@ +// +// PDFInfo.swift +// Reazy +// +// Created by 문인범 on 11/5/24. +// + +import Foundation + + +struct PDFInfo: Codable { + let title: String? + let names: [String]? + let date: Publication? + + struct Publication: Codable { + let date: String + let engDate: String + + enum CodingKeys: String, CodingKey { + case date = "@when" + case engDate = "#text" + } + } +} + + diff --git a/Project/Reazy/Domain/Models/PDFLayout.swift b/Project/Reazy/Domain/Models/PDFLayout.swift new file mode 100644 index 00000000..917d8d98 --- /dev/null +++ b/Project/Reazy/Domain/Models/PDFLayout.swift @@ -0,0 +1,26 @@ +// +// PDFInfo.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import Foundation + + +/** + Grobid 모델에서 PDF 분석 결과를 받아오는 구조체 + */ +struct PDFLayout: Codable { + let fig: [Figure] + let table: [Figure]? +} + +struct Figure: Codable { + let id: String + let head: String? + let label: String? + let figDesc: String? + let coords: [String] + let graphicCoord: [String]? +} diff --git a/Project/Reazy/Domain/Models/PaperInfo.swift b/Project/Reazy/Domain/Models/PaperInfo.swift new file mode 100644 index 00000000..8d8471d1 --- /dev/null +++ b/Project/Reazy/Domain/Models/PaperInfo.swift @@ -0,0 +1,40 @@ +// +// PaperInfo.swift +// Reazy +// +// Created by 유지수 on 10/24/24. +// + +import Foundation + +// 임의 모델 생성 +struct PaperInfo { + let id: UUID + var title: String + let thumbnail: Data + let url: Data + var lastModifiedDate: Date + var isFavorite: Bool + var memo: String? + var isFigureSaved: Bool + + init( + id: UUID = .init(), + title: String, + thumbnail: Data, + url: Data, + lastModifiedDate: Date = .init(), + isFavorite: Bool = false, + memo: String? = nil, + isFigureSaved: Bool = false + ) { + self.id = id + self.title = title + self.thumbnail = thumbnail + self.url = url + self.lastModifiedDate = lastModifiedDate + self.isFavorite = isFavorite + self.memo = memo + self.isFigureSaved = isFigureSaved + } +} diff --git a/Project/Reazy/Domain/Models/TableItem.swift b/Project/Reazy/Domain/Models/TableItem.swift new file mode 100644 index 00000000..a3c6ab15 --- /dev/null +++ b/Project/Reazy/Domain/Models/TableItem.swift @@ -0,0 +1,17 @@ +// +// TableItem.swift +// Reazy +// +// Created by 문인범 on 10/19/24. +// + +import Foundation +import PDFKit + +struct TableItem: Identifiable { + let id = UUID() + let table: PDFOutline + let level: Int + var children: [TableItem] = [] + var isExpanded: Bool = false +} diff --git a/Project/Reazy/Domain/Network/NWPathMonitor + Extension.swift b/Project/Reazy/Domain/Network/NWPathMonitor + Extension.swift new file mode 100644 index 00000000..b4bfaaad --- /dev/null +++ b/Project/Reazy/Domain/Network/NWPathMonitor + Extension.swift @@ -0,0 +1,30 @@ +// +// NWPathMonitor + Extension.swift +// Reazy +// +// Created by 문인범 on 11/8/24. +// + +import Network + + +extension NWPathMonitor { + static func startMonitoring(callBack: @escaping (Bool) -> Void) { + let monitor = NWPathMonitor() + + monitor.start(queue: .main) + + monitor.pathUpdateHandler = { path in + let isConnected = path.status == .satisfied + + if isConnected == true { + print("연결됨") + } else { + print("연결안됨") + } + + callBack(path.status == .satisfied) + monitor.cancel() + } + } +} diff --git a/Project/Reazy/Domain/Network/NetworkManager + Error.swift b/Project/Reazy/Domain/Network/NetworkManager + Error.swift new file mode 100644 index 00000000..d5776535 --- /dev/null +++ b/Project/Reazy/Domain/Network/NetworkManager + Error.swift @@ -0,0 +1,23 @@ +// +// NetworkManager.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import Foundation + +// MARK: - 네트워크 요청 관련 클래스 +class NetworkManager { + +} + + +// MARK: - 네트워크 에러 +enum NetworkManagerError: Error { + case invalidInfo // info.plist 오류 + case invalidURL // url 생성 오류 + case invalidPDF // pdf 없음 오류 + case badRequest // 200~299 번대가 아닐 시 + case corruptedPDF // 500번일 때(PDF OCR 미적용) +} diff --git a/Project/Reazy/Domain/Network/NetworkManager + Grobid.swift b/Project/Reazy/Domain/Network/NetworkManager + Grobid.swift new file mode 100644 index 00000000..52cb7ad7 --- /dev/null +++ b/Project/Reazy/Domain/Network/NetworkManager + Grobid.swift @@ -0,0 +1,174 @@ +// +// NetworkManager + Grobid.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import Foundation + +/** + Grobid 모델과 통신 관련 메소드 + */ +extension NetworkManager { + + /// Sample 데이터 불러오기 + static func getSamplePDFData() throws -> PDFLayout { + guard let samplePDFUrl = Bundle.main.url(forResource: "engPD5Output", withExtension: "json") else { + throw NetworkManagerError.invalidURL + } + + do { + let data = try Data(contentsOf: samplePDFUrl) + + let decodedResult = try JSONDecoder().decode(PDFLayout.self, from: data) + + return decodedResult + } catch { + throw error + } + } + + /// Sample 텍스트 좌표 필터링 메소드 + /* + static func filterData(input: PDFLayout, pageWidth: CGFloat, pageHeight: CGFloat) -> [FocusAnnotation] { + + var result = [FocusAnnotation]() + + for coords in input.div { + let header = coords.header + var currentPage = -1 + var x0 = -1.0 + var x1 = -1.0 + var y0 = -1.0 + var y1 = -1.0 + + var isNext = false + + for coord in coords.coords { + let array = coord.split(separator: ",") + + let page = Int(array[0])! + let tempX0 = Double(array[1])! + let tempX1 = Double(array[1])! + Double(array[3])! + let tempY0 = Double(array[2])! + let tempY1 = Double(array[2])! + Double(array[4])! + + + if x0 == -1 { + currentPage = page + x0 = tempX0 + x1 = tempX1 + y0 = tempY0 + y1 = tempY1 + + continue + } + + if tempX0 > pageWidth / 2.0 && !isNext { + currentPage = page + let focus = FocusAnnotation(page: currentPage, header: header, position: .init( + x: x0, + y: pageHeight - y1, + width: x1 - x0, + height: y1 - y0)) + + result.append(focus) + + x0 = tempX0 + x1 = tempX1 + y0 = tempY0 + y1 = tempY1 + + isNext = true + continue + + } else if currentPage != page { + let focus = FocusAnnotation(page: currentPage, header: header, position: .init( + x: x0, + y: pageHeight - y1, + width: x1 - x0, + height: y1 - y0)) + + result.append(focus) + + currentPage = page + + x0 = tempX0 + x1 = tempX1 + y0 = tempY0 + y1 = tempY1 + + isNext = false + continue + } + + x0 = min(x0, tempX0) + x1 = max(x1, tempX1) + y0 = min(y0, tempY0) + y1 = max(y1, tempY1) + } + + let focus = FocusAnnotation(page: currentPage, header: header, position: .init( + x: x0, + y: pageHeight - y1, + width: x1 - x0, + height: y1 - y0)) + + result.append(focus) + } + return result + } + */ + + /// Sample 이미지 좌표 필터링 메소드 + static func filterFigure(input: PDFLayout, pageWidth: CGFloat, pageHeight: CGFloat) -> [FigureAnnotation] { + + var result = [FigureAnnotation]() + + + for coords in input.fig { + let head = coords.head + var page = -1 + var x0 = -1.0 + var x1 = -1.0 + var y0 = -1.0 + var y1 = -1.0 + + for coord in coords.coords { + let array = coord.split(separator: ",") + + page = Int(array[0])! + let tempX0 = Double(array[1])! + let tempX1 = Double(array[1])! + Double(array[3])! + let tempY0 = Double(array[2])! + let tempY1 = Double(array[2])! + Double(array[4])! + + if x0 == -1 { + x0 = tempX0 + x1 = tempX1 + y0 = tempY0 + y1 = tempY1 + + continue + } + + x0 = min(x0, tempX0) + x1 = max(x1, tempX1) + y0 = min(y0, tempY0) + y1 = max(y1, tempY1) + } + + result.append(.init( + page: page, + head: head ?? "nil", + position: .init( + x: x0, + y: pageHeight - y1, + width: x1 - x0, + height: y1 - y0))) + } + + return result + } +} diff --git a/Project/Reazy/Domain/Network/NetworkManager + Server.swift b/Project/Reazy/Domain/Network/NetworkManager + Server.swift new file mode 100644 index 00000000..79ee55ba --- /dev/null +++ b/Project/Reazy/Domain/Network/NetworkManager + Server.swift @@ -0,0 +1,86 @@ +// +// NetworkManager + Server.swift +// Reazy +// +// Created by 문인범 on 11/3/24. +// + +import Foundation + +/** + pdf 분석 관련 메소드 + */ +extension NetworkManager { + + /// 서버에 pdf 데이터 전송 + static func fetchPDFExtraction(process: ServiceName, pdfURL: URL) async throws -> T { + guard let urlString = Bundle.main.object(forInfoDictionaryKey: "API_URL") as? String else { + throw NetworkManagerError.invalidInfo + } + + guard let url = URL(string: "https://" + urlString) else { + throw NetworkManagerError.invalidURL + } + + guard let pdfData = try? Data(contentsOf: pdfURL) else { + throw NetworkManagerError.invalidPDF + } + + // multipart data 구분자 설정 + let boundary = "Boundary-\(UUID().uuidString)" + + // HTTPRequest 생성 + var request = URLRequest(url: url) + + // Header 설정 + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue(process.rawValue, forHTTPHeaderField: "serviceName") + + // Body 설정 + // multipart/form-data 사용 + var body = Data() + let fileName = pdfURL.lastPathComponent + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: application/pdf\r\n\r\n".data(using: .utf8)!) + body.append(pdfData) + body.append("\r\n".data(using: .utf8)!) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + let (data, response) = try await URLSession.shared.upload(for: request, from: body) + + if let response = response as? HTTPURLResponse { + // 500 error, PDF OCR 적용이 안되어있음 + if (500 ..< 600 ~= response.statusCode) { + let decoder = JSONDecoder() + let errorResult = try! decoder.decode(ErrorDescription.self, from: data) + print("PDF extract error!, statusCode: \(response.statusCode)") + print(errorResult.error) + throw NetworkManagerError.corruptedPDF + } + + // 기타 요청 에러 + else if !(200 ..< 300 ~= response.statusCode) { + print("request error!, statusCode: \(response.statusCode)") + throw NetworkManagerError.badRequest + } + } + + let decoder = JSONDecoder() + let decodedData = try decoder.decode(T.self, from: data) + + return decodedData + } + + /// 네트워크 요청 헤더 + enum ServiceName: String { + case processHeaderDocument + case processFulltextDocument + } + + /// 에러 메시지 모델 + private struct ErrorDescription: Decodable { + let error: String + } +} diff --git a/Project/Reazy/Domain/Network/VoidResponse.swift b/Project/Reazy/Domain/Network/VoidResponse.swift new file mode 100644 index 00000000..19e45857 --- /dev/null +++ b/Project/Reazy/Domain/Network/VoidResponse.swift @@ -0,0 +1,12 @@ +// +// VoidResponse.swift +// Reazy +// +// Created by 유지수 on 11/6/24. +// + +import Foundation + +public struct VoidResponse: Decodable { + public init() {} +} diff --git a/Project/Reazy/Views/Home/HomeView.swift b/Project/Reazy/Views/Home/HomeView.swift new file mode 100644 index 00000000..148058a0 --- /dev/null +++ b/Project/Reazy/Views/Home/HomeView.swift @@ -0,0 +1,420 @@ +// +// HomeView.swift +// Reazy +// +// Created by 문인범 on 10/14/24. +// + +import SwiftUI + +enum Options { + case main + case search + case edit +} + +struct HomeView: View { + @EnvironmentObject var navigationCoordinator: NavigationCoordinator + @EnvironmentObject private var pdfFileManager: PDFFileManager + + @State var selectedMenu: Options = .main + @State var selectedPaperID: UUID? + + // 검색 모드 search text + @State private var searchText: String = "" + + @State private var isStarSelected: Bool = false + @State private var isFolderSelected: Bool = false + + @State private var isEditing: Bool = false + @State private var selectedItems: Set = [] + + @State private var isSearching: Bool = false + @State private var isEditingTitle: Bool = false + @State private var isEditingMemo: Bool = false + + var body: some View { + ZStack { + VStack(spacing: 0) { + ZStack { + Rectangle() + .foregroundStyle(.point1) + + HStack(spacing: 0) { + Image("icon") + .resizable() + .scaledToFit() + .frame(width: 54, height: 50) + .padding(.vertical, 31) + .padding(.leading, 28) + + Spacer() + + switch selectedMenu { + case .main: + MainMenuView( + selectedMenu: $selectedMenu, + isSearching: $isSearching, + isEditing: $isEditing, + selectedItems: $selectedItems, + selectedPaperID: $selectedPaperID) + + case .search: + SearchMenuView( + selectedMenu: $selectedMenu, + searchText: $searchText, + isSearching: $isSearching) + + case .edit: + EditMenuView( + selectedMenu: $selectedMenu, + selectedItems: $selectedItems, + isEditing: $isEditing) + } + } + } + .frame(height: 80) + + PaperListView( + selectedPaperID: $selectedPaperID, + selectedItems: $selectedItems, + isEditing: $isEditing, + isSearching: $isSearching, + isEditingTitle: $isEditingTitle, + isEditingMemo: $isEditingMemo, + searchText: $searchText + ) + } + .blur(radius: isEditingTitle || isEditingMemo ? 20 : 0) + + + Color.black + .opacity( isEditingTitle || isEditingMemo ? 0.5 : 0) + .ignoresSafeArea(edges: .bottom) + + + if isEditingTitle || isEditingMemo { + RenamePaperTitleView( + isEditingTitle: $isEditingTitle, + isEditingMemo: $isEditingMemo, + paperInfo: pdfFileManager.paperInfos.first { $0.id == selectedPaperID! }!) + } + } + .background(Color(hex: "F7F7FB")) + .overlay { + if pdfFileManager.isLoading { + ProgressView() + .progressViewStyle(.circular) + } + } + .statusBarHidden() + .animation(.easeInOut, value: isEditingTitle) + .animation(.easeInOut, value: isEditingMemo) + } +} + +#Preview { + HomeView() +} + + +/// 기본 화면 버튼 뷰 +private struct MainMenuView: View { + @EnvironmentObject var pdfFileManager: PDFFileManager + + @State private var isFileImporterPresented: Bool = false + @State private var errorAlert: Bool = false + @State private var errorStatus: ErrorStatus = .etc + + @Binding var selectedMenu: Options + @Binding var isSearching: Bool + @Binding var isEditing: Bool + @Binding var selectedItems: Set + @Binding var selectedPaperID: UUID? + + var body: some View { + HStack(spacing: 0) { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + selectedMenu = .search + } + isSearching.toggle() + }) { + Image(systemName: "magnifyingglass") + .resizable() + .scaledToFit() + .frame(height: 19) + .foregroundStyle(.gray100) + } + .padding(.trailing, 28) + + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + selectedMenu = .edit + } + isEditing.toggle() + selectedItems.removeAll() + }) { + Image(systemName: "checkmark.circle") + .resizable() + .scaledToFit() + .frame(height: 19) + .foregroundStyle(.gray100) + } + .padding(.trailing, 28) + + Button(action: { + self.isFileImporterPresented.toggle() + }) { + Text("가져오기") + .reazyFont(.button1) + .foregroundStyle(.gray100) + } + .padding(.trailing, 28) + } + .alert(isPresented: $errorAlert) { + // TODO: 예외 처리 수정 필요 + switch errorStatus { + case .accessError: + Alert( + title: Text("파일 접근이 불가능합니다."), + message: Text("다른 파일을 선택해주세요"), + dismissButton: .default(Text("Ok"))) + case .invalidURL: + Alert( + title: Text("잘못된 파일 경로"), + message: Text("파일이 올바른 경로에 있는지 확인해주세요"), + dismissButton: .default(Text("Ok"))) + + case .etc: + Alert(title: Text("알 수 없는 에러가 발생했습니다.")) + } + } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.pdf], + allowsMultipleSelection: false, + onCompletion: importPDFToDevice) + } + + private enum ErrorStatus { + case accessError + case invalidURL + case etc + } + + private func importPDFToDevice(result: Result<[Foundation.URL], any Error>) { + switch result { + case .success(let url): + do { + if let newPaperID = try pdfFileManager.uploadPDFFile(url: url) { + selectedPaperID = newPaperID + } + } catch { + print(String(describing: error)) + + if let error = error as? PDFUploadError { + switch error { + case .failedToAccessingSecurityScope: + self.errorStatus = .accessError + self.errorAlert.toggle() + case .invalidURL: + self.errorStatus = .invalidURL + self.errorAlert.toggle() + } + } + } + case .failure(let error): + print(String(describing: error)) + self.errorStatus = .etc + self.errorAlert.toggle() + } + } +} + +/// 검색 화면 버튼 뷰 +private struct SearchMenuView: View { + @Binding var selectedMenu: Options + @Binding var searchText: String + @Binding var isSearching: Bool + + var body: some View { + HStack(spacing: 0) { + SearchBar(text: $searchText) + .frame(width: 400) + + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + selectedMenu = .main + } + isSearching.toggle() + searchText = "" + }, label: { + Text("취소") + .reazyFont(.button1) + .foregroundStyle(.gray100) + }) + .padding(.trailing, 28) + } + } +} + +/// 수정 화면 버튼 뷰 +private struct EditMenuView: View { + @EnvironmentObject var pdfFileManager: PDFFileManager + @State private var isStarSelected: Bool = false + + @Binding var selectedMenu: Options + @Binding var selectedItems: Set + @Binding var isEditing: Bool + + + var body: some View { + HStack(spacing: 0) { + let selectedIDs: [UUID] = selectedItems.compactMap { index in + guard index < pdfFileManager.paperInfos.count else { return nil } + return pdfFileManager.paperInfos[index].id + } + + Button(action: { + isStarSelected.toggle() + pdfFileManager.updateFavorites(at: selectedIDs) + }, label : { + Image(systemName: isStarSelected ? "star.fill" : "star") + .resizable() + .scaledToFit() + .frame(height: 19) + .foregroundStyle(.gray100) + }) + .padding(.trailing, 28) + + Button(action: { + pdfFileManager.deletePDFFiles(at: selectedIDs) + }, label: { + Image(systemName: "trash") + .resizable() + .scaledToFit() + .frame(height: 19) + .foregroundStyle(.gray100) + }) + .padding(.trailing, 28) + + Button(action: { + selectedMenu = .main + isEditing = false + selectedItems.removeAll() + isStarSelected = false + }, label: { + Text("취소") + .reazyFont(.button1) + .foregroundStyle(.gray100) + }) + .padding(.trailing, 28) + } + } +} + +/// 논문 타이틀 수정 뷰 +private struct RenamePaperTitleView: View { + @EnvironmentObject private var pdfFileManager: PDFFileManager + + @State private var text: String = "" + + @Binding var isEditingTitle: Bool + + @Binding var isEditingMemo: Bool + + let paperInfo: PaperInfo + + var body: some View { + ZStack { + VStack { + HStack { + Button { + if isEditingTitle { + isEditingTitle.toggle() + } else { + isEditingMemo = false + } + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 17) + } + .foregroundStyle(.gray100) + .padding(28) + + Spacer() + + Button("완료") { + if isEditingTitle { + pdfFileManager.updateTitle(at: paperInfo.id, title: text) + isEditingTitle = false + } else { + pdfFileManager.updateMemo(at: paperInfo.id, memo: text) + self.pdfFileManager.memoText = text + isEditingMemo = false + } + } + .reazyFont(.button1) + .foregroundStyle(.gray100) + .padding(28) + } + + Spacer() + } + HStack(spacing: 54) { + Image(uiImage: .init(data: paperInfo.thumbnail)!) + .resizable() + .scaledToFit() + .frame(width: 196) + + VStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .foregroundStyle(.gray100) + .frame(width: 400, height: isEditingTitle ? 52 : 180) + + RoundedRectangle(cornerRadius: 12) + .stroke(lineWidth: 1) + .foregroundStyle(.gray400) + .frame(width: 400, height: isEditingTitle ? 52 : 180) + } + .frame(width: 400, height: isEditingTitle ? 52 : 180) + .overlay(alignment: isEditingTitle ? .center : .topLeading) { + TextField( isEditingTitle ? "제목을 입력해주세요." : "내용을 입력해주세요.", text: $text, axis: .vertical) + .lineLimit( isEditingTitle ? 1 : 6) + .padding(.horizontal, 16) + .padding(.vertical, isEditingTitle ? 0 : 16) + .font(.custom(ReazyFontType.pretendardMediumFont, size: 16)) + .foregroundStyle(.gray800) + } + .overlay(alignment: isEditingTitle ? .trailing : .bottomTrailing) { + if !self.text.isEmpty { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(.gray600) + .padding(.bottom, isEditingTitle ? 0 : 15) + .padding(.trailing, isEditingTitle ? 10 : 15) + .onTapGesture { + text = "" + } + } + } + + Text(isEditingTitle ? "논문 제목을 입력해 주세요" : "논문에 대한 메모를 남겨주세요") + .reazyFont(.button1) + .foregroundStyle(.comment) + } + } + } + .onAppear { + if isEditingTitle { + self.text = paperInfo.title + } else { + self.text = paperInfo.memo ?? "" + } + } + } +} diff --git a/Project/Reazy/Views/Home/Paper/PaperInfoView.swift b/Project/Reazy/Views/Home/Paper/PaperInfoView.swift new file mode 100644 index 00000000..df7c734e --- /dev/null +++ b/Project/Reazy/Views/Home/Paper/PaperInfoView.swift @@ -0,0 +1,256 @@ +// +// PaperInfoView.swift +// Reazy +// +// Created by 유지수 on 10/18/24. +// + +import SwiftUI + +struct PaperInfoView: View { + @EnvironmentObject var pdfFileManager: PDFFileManager + + let id: UUID + let image: Data + let title: String + @State var memo: String? + var isFavorite: Bool + + @State var isStarSelected: Bool + + @State private var isDeleteConfirm: Bool = false + + @Binding var isEditingTitle: Bool + @Binding var isEditingMemo: Bool + + let onNavigate: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(spacing: 0) { + Image(uiImage: .init(data: image) ?? .init(resource: .testThumbnail)) + .resizable() + .scaledToFit() + .padding(.horizontal, 30) + + Text(title) + .reazyFont(.text1) + .foregroundStyle(.gray900) + .padding(.horizontal, 30) + .padding(.top, 14) + .lineLimit(2) + + HStack(spacing: 0) { + Menu { + Button("제목 수정", systemImage: "pencil") { + self.isEditingTitle = true + } + } label: { + RoundedRectangle(cornerRadius: 14) + .frame(width: 40, height: 40) + .foregroundStyle(.gray400) + .overlay( + Image(systemName: "ellipsis") + .font(.system(size: 14)) + .foregroundStyle(.gray600) + ) + } + .padding(.trailing, 6) + + Button(action: { + isStarSelected.toggle() + pdfFileManager.updateFavorite(at: id, isFavorite: isStarSelected) + }) { + RoundedRectangle(cornerRadius: 14) + .frame(width: 40, height: 40) + .foregroundStyle(.gray400) + .overlay( + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 14)) + .foregroundStyle(isFavorite ? .primary1 : .gray600) + ) + } + .padding(.trailing, 6) + + Button(action: { + self.isDeleteConfirm.toggle() + }) { + RoundedRectangle(cornerRadius: 14) + .frame(width: 40, height: 40) + .foregroundStyle(.gray400) + .overlay( + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundStyle(.gray600) + ) + } + + Spacer() + + actionButton() + } + .padding(.horizontal, 30) + .padding(.top, 16) + + VStack(spacing: 0) { + Rectangle() + .frame(height: 1) + .padding(.bottom, 10) + .foregroundStyle(.primary3) + + HStack(spacing: 0) { + Text("메모") + .reazyFont(.button1) + .foregroundStyle(.black) + + Spacer() + + if !(self.memo == nil) { + Menu { + Button("수정", systemImage: "pencil") { + self.isEditingMemo = true + } + + Button("삭제", systemImage: "trash", role: .destructive) { + pdfFileManager.deleteMemo(at: id) + self.memo = nil + } + + } label: { + Image(systemName: "ellipsis.circle") + .resizable() + .scaledToFit() + .frame(width: 17) + .foregroundStyle(.gray600) + } + } else { + Button { + self.memo = "" + self.isEditingMemo = true + } label: { + Image(systemName: "plus") + .resizable() + .scaledToFit() + .frame(width: 17) + .foregroundStyle(.gray600) + } + } + + } + .padding(.bottom, 13) + + if self.memo != nil { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 10) + .foregroundStyle(.gray200) + + + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1) + .foregroundStyle(.gray550) + + VStack { + Text(self.memo!) + .lineLimit(4) + .reazyFont(.body2) + .foregroundStyle(.gray700) + .multilineTextAlignment(.leading) + + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + + } + .frame(maxHeight: 120) + } + } + .padding(.top, 24) + .padding(.horizontal, 30) + + Spacer() + } + .padding(.top, 36) + .background(alignment: .bottom) { + LinearGradient(colors: [.init(hex: "DADBEA"), .clear], startPoint: .bottom, endPoint: .top) + .frame(height: 185) + } + .onChange(of: self.id) { + let paperInfo = self.pdfFileManager.paperInfos.first { $0.id == self.id }! + self.memo = paperInfo.memo + } + .onChange(of: self.pdfFileManager.memoText) { + self.memo = self.pdfFileManager.memoText + } + .onChange(of: self.isEditingMemo) { + self.pdfFileManager.memoText = memo! + } + .alert(isPresented: $isDeleteConfirm) { + Alert( + title: Text("정말 삭제하시겠습니까?"), + message: Text("삭제된 파일은 복구할 수 없습니다."), + primaryButton: .destructive(Text("삭제")) { + pdfFileManager.deletePDFFile(at: id) + onDelete() + }, + secondaryButton: .default(Text("취소"))) + } + } + + @ViewBuilder + private func divider() -> some View { + Rectangle() + .frame(height: 1) + .padding(.top, 8) + .padding(.bottom, 8) + .foregroundStyle(.primary3) + } + + @ViewBuilder + private func actionButton() -> some View { + Button(action: { + onNavigate() + pdfFileManager.updateLastModifiedDate(at: id, lastModifiedDate: Date()) + }) { + HStack(spacing: 0) { + Text("읽기 ") + Image(systemName: "arrow.up.right") + } + .foregroundStyle(.gray100) + .reazyFont(.button2) + .padding(.horizontal, 21) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 18) + .foregroundStyle( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex:"3F3E7E"), location: 0), + .init(color: Color(hex: "313070"), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: Color(hex: "383582").opacity(0.2), radius: 30, x: 0, y: 6) + ) + } + .frame(height: 40) + } +} + + +#Preview { + PaperInfoView( + id: .init(), + image: .init(), + title: "A review of the global climate change impacts, adaptation, and sustainable mitigation measures", + memo: "", + isFavorite: false, + isStarSelected: false, + isEditingTitle: .constant(false), + isEditingMemo: .constant(false), + onNavigate: {}, + onDelete: {} + ) +} diff --git a/Project/Reazy/Views/Home/Paper/PaperListCell.swift b/Project/Reazy/Views/Home/Paper/PaperListCell.swift new file mode 100644 index 00000000..e712fe1e --- /dev/null +++ b/Project/Reazy/Views/Home/Paper/PaperListCell.swift @@ -0,0 +1,93 @@ +// +// PaperListCell.swift +// Reazy +// +// Created by 유지수 on 10/14/24. +// + +import SwiftUI + +struct PaperListCell: View { + + let title: String + let date: String + let isSelected: Bool + let isEditing: Bool + let isEditingSelected: Bool + let onSelect: () -> Void + let onEditingSelect: () -> Void + + var body: some View { + ZStack { + if isSelected && !isEditing { + RoundedRectangle(cornerRadius: 14) + .fill(.primary2) + .padding(.vertical, 3) + .padding(.horizontal, 8) + } + + HStack(spacing: 0) { + if isEditing { + Image(systemName: isEditingSelected ? "checkmark.circle.fill" : "circle") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(isEditingSelected ? .primary1 : .primary4) + .onTapGesture { + onEditingSelect() + } + .padding(.trailing, 20) + } + + RoundedRectangle(cornerRadius: 10) + .frame(width: 42, height: 42) + .foregroundStyle(.gray500) + .overlay( + Image("document") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 21) + .foregroundStyle(.gray100) + ) + + VStack(alignment: .leading, spacing: 0) { + Text(title) + .lineLimit(2) + .reazyFont(.h2) + .foregroundStyle(.gray900) + .padding(.bottom, 6) + Text(date) + .reazyFont(.h4) + .foregroundStyle(.gray600) + } + .padding(.leading, 15) + + Spacer() + } + .background(.clear) + .padding(.horizontal, 22) + .padding(.vertical, 15) + .contentShape(Rectangle()) + .onTapGesture { + if isEditing { + onEditingSelect() + } else { + onSelect() + } + } + } + } +} + +#Preview { + PaperListCell( + title: "test", + date: "1시간 전", + isSelected: false, + isEditing: true, + isEditingSelected: false, + onSelect: {}, + onEditingSelect: {} + ) +} diff --git a/Project/Reazy/Views/Home/Paper/PaperListView.swift b/Project/Reazy/Views/Home/Paper/PaperListView.swift new file mode 100644 index 00000000..4a76c8f9 --- /dev/null +++ b/Project/Reazy/Views/Home/Paper/PaperListView.swift @@ -0,0 +1,367 @@ +// +// PaperListView.swift +// Reazy +// +// Created by 유지수 on 10/17/24. +// + +import SwiftUI +import Combine + +struct PaperListView: View { + @EnvironmentObject var pdfFileManager: PDFFileManager + @EnvironmentObject var navigationCoordinator: NavigationCoordinator + + @Binding var selectedPaperID: UUID? + @Binding var selectedItems: Set + @State private var isFavoritesSelected: Bool = false + @State private var isNavigationPushed: Bool = false + + @Binding var isEditing: Bool + @Binding var isSearching: Bool + @Binding var isEditingTitle: Bool + @Binding var isEditingMemo: Bool + @Binding var searchText: String + + @State private var keyboardHeight: CGFloat = 0 + + @State private var timerCancellable: Cancellable? + + var filteredPaperInfos: [PaperInfo] { + var infos = isFavoritesSelected + ? pdfFileManager.paperInfos.filter { $0.isFavorite }.sorted(by: { $0.lastModifiedDate > $1.lastModifiedDate }) + : pdfFileManager.paperInfos.sorted(by: { $0.lastModifiedDate > $1.lastModifiedDate }) + + if !searchText.isEmpty { + infos = infos.filter { $0.title.localizedCaseInsensitiveContains(searchText) } + } + return infos + } + + @State private var isIPadMini: Bool = false + @State private var isVertical = false + + var body: some View { + // 화면 비율에 따라서 리스트 크기 설정 (반응형 UI) + GeometryReader { geometry in + HStack(spacing: 0) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + + Button(action: { + isFavoritesSelected = false + }, label: { + Text("전체 논문") + .reazyFont(isFavoritesSelected ? .h2 : .text3) + .foregroundStyle(isFavoritesSelected ? .primary4 : .primary1) + }) + + Rectangle() + .foregroundStyle(.gray500) + .frame(width: 1, height: 20) + .padding(.horizontal, 16) + + Button(action: { + isFavoritesSelected = true + // TODO: - 즐겨찾기 filter 적용 필요 + }, label: { + Text("즐겨찾기") + .reazyFont(isFavoritesSelected ? .text3 : .h2) + .foregroundStyle(isFavoritesSelected ? .primary1 : .primary4) + }) + + Spacer() + } + .padding(.leading, 28) + .padding(.vertical, 17) + + Divider() + + if filteredPaperInfos.isEmpty { + if isSearching { + Spacer() + + Text("\"\(searchText)\"와\n일치하는 결과가 없어요") + .reazyFont(.h5) + .foregroundStyle(.gray600) + .multilineTextAlignment(.center) + .padding(.bottom, keyboardHeight) + + Spacer() + } else { + Spacer() + + Image("empty") + .resizable() + .scaledToFit() + .frame(height: 146) + .padding(.bottom, 11) + Text("새로운 논문을 가져와주세요") + .reazyFont(.h5) + .foregroundStyle(.gray550) + .padding(.bottom, 80) + + Spacer() + } + } else { + // MARK: - CoreData + if filteredPaperInfos.isEmpty { + VStack(spacing: 11) { + Spacer() + Image(.homePlaceholder) + .resizable() + .scaledToFit() + .frame(width: 110) + + Text("새로운 논문을 가져와주세요") + .reazyFont(.h5) + .foregroundStyle(.gray550.opacity(0.3)) + Spacer() + } + } else { + ScrollView { + VStack(spacing: 0) { + ForEach(0.. $1.lastModifiedDate }) + : pdfFileManager.paperInfos.sorted(by: { $0.lastModifiedDate > $1.lastModifiedDate }) + + if selectedPaperID == nil, let firstPaper = filteredPaperInfos.first { + selectedPaperID = firstPaper.id + } + + pdfFileManager.paperInfos = filteredPaperInfos + } +} + +extension PaperListView { + private func timeAgoString(from date: Date) -> String { + let calendar = Calendar.current + + if calendar.isDateInToday(date) { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "오늘 HH:mm" + return dateFormatter.string(from: date) + } else if calendar.isDateInYesterday(date) { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "어제 HH:mm" + return dateFormatter.string(from: date) + } else { + // 이틀 전 이상의 날짜 포맷으로 반환 + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy. MM. dd. a h:mm" + dateFormatter.amSymbol = "오전" + dateFormatter.pmSymbol = "오후" + return dateFormatter.string(from: date) + } + } +} + +extension PaperListView { + private func detectIPadMini() { + if UIDevice.current.userInterfaceIdiom == .pad { + let screenSize = UIScreen.main.nativeBounds.size + let isMiniSize = (screenSize.width == 1536 && screenSize.height == 2048) || + (screenSize.width == 1488 && screenSize.height == 2266) + self.isIPadMini = isMiniSize + } + } + + private func updateOrientation(with geometry: GeometryProxy) { + isVertical = geometry.size.height > geometry.size.width + } +} + + +#Preview { + let manager = PDFFileManager(paperService: PaperDataService.shared) + + PaperListView( + selectedPaperID: .constant(nil), + selectedItems: .constant([]), + isEditing: .constant(false), + isSearching: .constant(false), + isEditingTitle: .constant(false), + isEditingMemo: .constant(false), + searchText: .constant("") + ) + .environmentObject(manager) + .onAppear { + manager.uploadSampleData() + } +} diff --git a/Project/Reazy/Views/PDF/BubbleView.swift b/Project/Reazy/Views/PDF/BubbleView.swift new file mode 100644 index 00000000..7bd6e68b --- /dev/null +++ b/Project/Reazy/Views/PDF/BubbleView.swift @@ -0,0 +1,222 @@ +import SwiftUI +import Translation + +@available(iOS 18.0, *) +struct BubbleView: View { + @EnvironmentObject var viewModel: MainPDFViewModel + @EnvironmentObject var floatingViewModel: FloatingViewModel + + @Binding var selectedText: String + @Binding var bubblePosition: CGRect + @Binding var isPaperViewFirst: Bool + + @State private var targetText = "" // 번역 결과 텍스트 + @State private var configuration: TranslationSession.Configuration? + + @State private var maxBubbleWidth: CGFloat = 400 // bubble 최대 너비 + @State private var maxBubbleHeight: CGFloat = 280 // bubble 최대 높이 + @State private var textHeight: CGFloat = 0 // 텍스트 높이 저장 + @State private var textWidth: CGFloat = 100 // 텍스트 너비 저장 + + // 말풍선 붙는 위치 + enum BubbleDirection { + case left, right, bottom + } + + @State private var bubbleDirection: BubbleDirection = .left + + @State private var updatedBubblePosition: CGPoint = .zero // 조정된 bubble view 위치 + @State private var isTranslationComplete: Bool = false // 번역 완료 되었는지 확인해 뷰 새로 그리기 위한 flag + + var body: some View { + // 18.0 이상 버전에서 보여줄 화면 + GeometryReader { geometry in + VStack(alignment: .center, spacing: 0) { + if isTranslationComplete { + ZStack (alignment: .center) { + // 배경에 깔릴 테두리 용 삼각형 + Image(systemName: "triangle.fill") + .resizable() + .foregroundStyle(.primary3) + .frame(width: 30, height: 22) // 크기 지정 (테두리 역할이라 + 2씩) + .rotationEffect( + Angle(degrees: { + switch bubbleDirection { + case .left: + return 90 + case .right: + return 270 + case .bottom: + return 0 + } + }()) + ) + .offset( + x: { + switch bubbleDirection { + case .left: + return (min(textWidth, maxBubbleWidth) / 2) + 2 + case .right: + return -(min(textWidth, maxBubbleWidth) / 2) - 2 + case .bottom: + return 0 + } + }(), + y: bubbleDirection == .bottom ? -(min(textHeight, maxBubbleHeight) / 2) - 2 : 0 // 전체 높이의 중간에 화살표가 오게 조정 + ) + .shadow(color: Color(hex: "#767676").opacity(0.25), radius: 6, x: 0, y: 2) + + RoundedRectangle(cornerRadius: 8) + .fill(.gray200) + .stroke(.primary3, lineWidth: 1) + .frame(width: min(textWidth, maxBubbleWidth), height: min(textHeight, maxBubbleHeight)) + .shadow(color: Color(hex: "#767676").opacity(0.25), radius: 6, x: 0, y: 2) + + // 말풍선 옆에 붙어있는 삼각형 + Image(systemName: "triangle.fill") + .resizable() + .foregroundStyle(.gray200) + .frame(width: 28, height: 20) // 크기 지정 + .rotationEffect( + Angle(degrees: { + switch bubbleDirection { + case .left: + return 90 + case .right: + return 270 + case .bottom: + return 0 + } + }()) + ) + .offset( + x: { + switch bubbleDirection { + case .left: + return (min(textWidth, maxBubbleWidth) / 2) + 1 + case .right: + return -(min(textWidth, maxBubbleWidth) / 2) - 1 + case .bottom: + return 0 + } + }(), + y: bubbleDirection == .bottom ? -(min(textHeight, maxBubbleHeight) / 2) - 2 : 0 // 높이의 반만큼 화살표 위로 이동 + ) + + ScrollView() { + VStack { + Text(targetText) + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.point2) + .lineSpacing(3) + .padding(.vertical, 14) + .padding(.horizontal, 18) + .background( + GeometryReader { textGeometry in + Color.clear + .preference(key: ViewHeightKey.self, value: textGeometry.size.height) + } + ) + } + .frame(maxWidth: maxBubbleWidth) // maxBubbleWidth로 최대 너비 설정 + } + .frame(width: min(textWidth, maxBubbleWidth), height: min(textHeight, maxBubbleHeight)) + } + } + } + .onAppear { + bubblePositionForScreen(bubblePosition, in: geometry.size) + textWidth = bubblePosition.width * 1.5 // 글자 수 적을 때 너비 여유롭게 + triggerTranslation() + } + .position(updatedBubblePosition) + .onChange(of: selectedText) { + isTranslationComplete = false // 번역 완료 되었을 때 뷰 다시 그리게 false 처리 + bubblePositionForScreen(bubblePosition, in: geometry.size) + textWidth = bubblePosition.width * 1.5 + triggerTranslation() + } + .onPreferenceChange(ViewHeightKey.self) { height in + textHeight = height + } + .translationTask(configuration) { session in + do { + let cleanedText = removeHyphen(in: selectedText) + let response = try await session.translate(cleanedText) + targetText = response.targetText + if !targetText.isEmpty { + isTranslationComplete = true + } + } catch { + print(" 번역 중 에러 발생 ") + } + } + + } + } + + // 번역 + private func triggerTranslation() { + guard configuration == nil else { + configuration?.invalidate() + return + } + + // 현재 언어는 영어 -> 한국어로 고정 + configuration = .init(source: Locale.Language(identifier: "en"), + target: Locale.Language(identifier: "ko")) + } + + // BubbleView 위치 조정하는 함수 + private func bubblePositionForScreen(_ rect: CGRect, in screenSize: CGSize) { + if floatingViewModel.splitMode { // 스플릿 뷰 켜져 있으면 항상 아래에 붙게 + // 말풍선이 선택 영역 아래에 붙음 + bubbleDirection = .bottom + if isPaperViewFirst { + updatedBubblePosition = CGPoint(x: rect.midX, y: rect.maxY + rect.height/3) + } else { // 스플릿 뷰에서 논문이 오른쪽에 붙으면 스크린의 반만큼 왼쪽으로 이동시킴 + updatedBubblePosition = CGPoint(x: rect.midX - (screenSize.width), y: rect.maxY + rect.height/3) + } + } else if rect.width > (screenSize.width / 2) { // 선택 영역이 차지하는 범위가 1/2 이상이면 + // 말풍선이 선택 영역 아래에 붙음 + bubbleDirection = .bottom + updatedBubblePosition = CGPoint(x: rect.midX, y: rect.maxY) + } else if rect.maxX > (screenSize.width / 2) && rect.minX > (screenSize.width / 3) { + // 말풍선이 선택 영역 왼쪽에 붙음 + bubbleDirection = .left + updatedBubblePosition = CGPoint(x: rect.minX - maxBubbleWidth + 150, y: rect.midY - 100) + } else if rect.maxX < (screenSize.width / 2) && rect.minX < (screenSize.width / 3){ + // 말풍선이 선택 영역 오른쪽에 붙음 + bubbleDirection = .right + updatedBubblePosition = CGPoint(x: rect.maxX + maxBubbleWidth - 150, y: rect.midY - 100) + } else { + // 말풍선이 선택 영역 아래에 붙음 + bubbleDirection = .bottom + updatedBubblePosition = CGPoint(x: rect.midX, y: rect.maxY + rect.height/3) + } + return + } + + // 줄바꿈 전에 있는 '-'를 제거하는 함수 + func removeHyphen(in text: String) -> String { + var result = "" + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + + for line in lines { + if line.hasSuffix("-") { + result += line.dropLast() // 줄 끝 '-'를 제거하고 줄바꿈 추가 + } else { + result += line + "\n" // '-'가 없는 줄은 그대로 추가 + } + } + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// 텍스트 높이 계산에 사용할 PreferenceKey +private struct ViewHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} diff --git a/Project/Reazy/Views/PDF/BubbleViewOlderVer.swift b/Project/Reazy/Views/PDF/BubbleViewOlderVer.swift new file mode 100644 index 00000000..e864acf5 --- /dev/null +++ b/Project/Reazy/Views/PDF/BubbleViewOlderVer.swift @@ -0,0 +1,47 @@ +// +// BubbleViewOlderVer.swift +// Reazy +// +// Created by Minjung Lee on 10/31/24. +// + +import SwiftUI + +struct BubbleViewOlderVer: View { + + var body: some View { + // 18.0 미만 버전에서 보여줄 화면 + ZStack(alignment: .center) { + Image(systemName: "triangle.fill") + .resizable() + .foregroundStyle(.primary3) + .frame(width: 30, height: 22) // 크기 지정 (테두리 역할이라 + 2씩) + .offset(y: -77/2) + .shadow(color: Color(hex: "#767676").opacity(0.25), radius: 6, x: 0, y: 2) + + RoundedRectangle(cornerRadius: 8) + .fill(.gray200) + .stroke(.primary3, lineWidth: 1) + .frame(width: 400, height: 77) + .shadow(color: Color(hex: "#767676").opacity(0.25), radius: 6, x: 0, y: 2) + + Image(systemName: "triangle.fill") + .resizable() + .foregroundStyle(.gray200) + .frame(width: 28, height: 20) // 크기 지정 + .offset(y: -77/2) + + VStack(alignment: .center) { + Text("해당 번역 기능은 iPadOS 18.0 이상에서만 사용 가능합니다.\niPadOS 18.0 미만 버전에서는 소프트웨어 업데이트가 필요합니다.") + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.point2) + .lineSpacing(3) + } + .multilineTextAlignment(.center) + .padding(.vertical, 14) + .padding(.horizontal, 18) + } + .offset(x: 116, y: 16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // 화면 중간 상단에 고정 + } +} diff --git a/Project/Reazy/Views/PDF/CommentCell.swift b/Project/Reazy/Views/PDF/CommentCell.swift new file mode 100644 index 00000000..941e303f --- /dev/null +++ b/Project/Reazy/Views/PDF/CommentCell.swift @@ -0,0 +1,78 @@ +// +// CommentCell.swift +// Reazy +// +// Created by 김예림 on 11/7/24. +// + +import SwiftUI + +struct CommentCell: View { + @EnvironmentObject var pdfViewModel: MainPDFViewModel + @StateObject var viewModel: CommentViewModel + + var comment: Comment // 선택된 comment + + var body: some View { + HStack(alignment: .center){ + + Divider() + .frame(width: 2, height: 14) + .background(.point4) + + Text(comment.selectedText.replacingOccurrences(of: "\n", with: "")) + .reazyFont(.body3) + .foregroundStyle(.point4) + .lineLimit(1) + } + .padding(.bottom, 8) + .padding(.trailing, 16) + .padding(.top, 18) + + Text(comment.text) + .reazyFont(.body1) + .foregroundStyle(.point2) + .padding(.trailing, 16) + + HStack{ + Spacer() + Menu { + ControlGroup { + Button(action: { + viewModel.comment = comment + viewModel.isEditMode = true + pdfViewModel.isCommentTapped = false + }, label: { + HStack{ + Image(systemName: "pencil.line") + .font(.system(size: 12)) + .padding(.trailing, 6) + Text("수정") + .reazyFont(.body3) + } + }) + .foregroundStyle(.gray600) + + Button(action: { + viewModel.deleteComment(commentId: comment.id) + pdfViewModel.isCommentTapped = false + pdfViewModel.setHighlight(selectedComments: pdfViewModel.selectedComments, isTapped: pdfViewModel.isCommentTapped) + }, label: { + HStack{ + Image(systemName: "trash") + .font(.system(size: 12)) + .padding(.trailing, 6) + Text("삭제") + .reazyFont(.body3) + } + }).foregroundStyle(.gray600) + } + } label: { + Image(systemName: "ellipsis.circle") + .foregroundStyle(.gray500) + .font(.system(size: 20)) + } + } + .padding([.trailing, .bottom], 9) + } +} diff --git a/Project/Reazy/Views/PDF/CommentGroupView.swift b/Project/Reazy/Views/PDF/CommentGroupView.swift new file mode 100644 index 00000000..be92f94e --- /dev/null +++ b/Project/Reazy/Views/PDF/CommentGroupView.swift @@ -0,0 +1,184 @@ +// +// CommentView.swift +// Reazy +// +// Created by 김예림 on 10/24/24. +// + +import SwiftUI +import PDFKit + +struct CommentGroupView: View { + @EnvironmentObject var pdfViewModel: MainPDFViewModel + @StateObject var viewModel: CommentViewModel + @State var text: String = "" + + let changedSelection: PDFSelection + + var body: some View { + ZStack { + VStack { + if pdfViewModel.isCommentTapped { + CommentView(viewModel: viewModel, selectedComments: pdfViewModel.selectedComments) + } else { + CommentInputView(viewModel: viewModel, changedSelection: changedSelection) + } + } + .background(Color.gray100) + .cornerRadius(12) + .frame(width: 357) + .border(.primary2, width: 1) + .shadow(color: Color(hex: "#6E6E6E").opacity(0.25), radius: 10, x: 0, y: 2) + } + .onChange(of: viewModel.isEditMode) { + print("editmode") + } + } +} + +// MARK: - CommentGrouopView 분리 + +// 저장된 코멘트 +private struct CommentView: View { + @StateObject var viewModel: CommentViewModel + var selectedComments: [Comment] + var body: some View { + + VStack(alignment: .leading, spacing: 0) { + ForEach(selectedComments.indices, id: \.self) { index in + CommentCell(viewModel: viewModel, comment: selectedComments[index]) + .padding(.leading, 16) + + if index < selectedComments.count - 1 { + Divider() + .frame(height: 1) + .foregroundStyle(.primary2) + .padding(0) + } + } + } + .frame(minWidth: 357, minHeight: 78) + .foregroundStyle(.point2) + + } +} + +// 코멘트 입력 창 +private struct CommentInputView: View { + @EnvironmentObject var pdfViewModel: MainPDFViewModel + @StateObject var viewModel: CommentViewModel + + @State var text: String = "" + @State private var commentHeight: CGFloat = 20 + + let changedSelection: PDFSelection + let placeHolder: Text = .init("코멘트 추가") + + var body: some View { + LazyVStack(alignment: .leading) { + TextField("\(placeHolder.foregroundStyle(Color.primary4))", text: $text, axis:.vertical) + .lineLimit(5) + .reazyFont(.body1) + .foregroundStyle(.point2) + .padding(.horizontal, 18) + + HStack{ + Spacer() + + Button(action: { + + defer { + viewModel.isEditMode = false + } + pdfViewModel.isCommentTapped = false + pdfViewModel.setHighlight(selectedComments: pdfViewModel.selectedComments, isTapped: pdfViewModel.isCommentTapped) + + if !text.isEmpty { + if viewModel.isEditMode { + guard let commentId = viewModel.comment?.id else {return} + let comments = viewModel.comments + guard let idx = viewModel.comments.firstIndex(where: { $0.id == commentId }) else { return } + + let resultComment = Comment( + id: comments[idx].id, + buttonId: comments[idx].buttonId, + text: self.text, + selectedText: comments[idx].selectedText, + selectionsByLine: comments[idx].selectionsByLine, + pages: comments[idx].pages, + bounds: comments[idx].bounds) + + _ = viewModel.commentService.editCommentData(for: viewModel.paperInfo.id, with: resultComment) + viewModel.comments[idx] = resultComment + return + } + pdfViewModel.isCommentSaved = true + viewModel.addComment(text: text, + selection: changedSelection + ) + text = "" // 코멘트 추가 후 텍스트 필드 비우기 + } + viewModel.isEditMode = false + }, label: { + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(text.isEmpty ? .primary4 : .primary1) + .font(.system(size: 20)) + }) + .padding(.trailing, 9) + .disabled(text.isEmpty) + } + } + .padding(.top, 16) + .padding(.bottom, 9) + .onReceive(self.viewModel.$comment) { + guard let comment = $0 else { return } + if viewModel.isEditMode { + self.text = comment.text + } + } + } +} + +// 수정,삭제 뷰 + +private struct CommentMenuView: View { + var body: some View { + HStack{ + Menu { + Button(action: { + //수정 액션 + }, label: { + HStack{ + Image(systemName: "pencil.line") + .font(.system(size: 12)) + .padding(.trailing, 6) + Text("수정") + .reazyFont(.body3) + } + }) + .foregroundStyle(.gray600) + + Button(action: { + //수정 액션 + }, label: { + HStack{ + Image(systemName: "trash") + .font(.system(size: 12)) + .padding(.trailing, 6) + Text("삭제") + .reazyFont(.body3) + } + }).foregroundStyle(.gray600) + } label: { + + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .background(.gray100) + .border(.primary2, width: 1) + .frame(minWidth: 130) + .cornerRadius(8) + .shadow(color: Color(hex: "#6E6E6E").opacity(0.25), radius: 10, x: 0, y: 2) + } +} diff --git a/Project/Reazy/Views/PDF/Drawing/DrawingAnnotation.swift b/Project/Reazy/Views/PDF/Drawing/DrawingAnnotation.swift new file mode 100644 index 00000000..6813fb53 --- /dev/null +++ b/Project/Reazy/Views/PDF/Drawing/DrawingAnnotation.swift @@ -0,0 +1,54 @@ +// +// DrawingAnnotation.swift +// Reazy +// +// Created by Minjung Lee on 10/30/24. +// + +import UIKit +import PDFKit + +class DrawingAnnotation: PDFAnnotation { + public var path = UIBezierPath() + public var uuid: UUID // 고유 ID 추가 + + init(bounds: CGRect, forType type: PDFAnnotationSubtype, withProperties properties: [String: Any]? = nil) { + self.uuid = UUID() // 고유 ID 생성 + super.init(bounds: bounds, forType: type, withProperties: properties) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // 펜슬이 지나간 자리 그리기 + override func draw(with box: PDFDisplayBox, in context: CGContext) { + let pathCopy = path.copy() as! UIBezierPath + UIGraphicsPushContext(context) + context.saveGState() + + context.setShouldAntialias(true) + + color.set() + pathCopy.lineJoinStyle = .round + pathCopy.lineCapStyle = .round + pathCopy.lineWidth = border?.lineWidth ?? 1.0 + pathCopy.stroke() + + context.restoreGState() + UIGraphicsPopContext() + } +} + +extension PDFAnnotation { + func contains(point: CGPoint) -> Bool { + var hitPath: CGPath? + + if let path = paths?.first { + // 얼마나 가까이 닿아야 닿았다고 인식할 건지 + hitPath = path.cgPath.copy(strokingWithWidth: 1.0, lineCap: .round, lineJoin: .round, miterLimit: 0) + } + + return hitPath?.contains(point) ?? false + } +} diff --git a/Project/Reazy/Views/PDF/Drawing/DrawingGestureRecognizer.swift b/Project/Reazy/Views/PDF/Drawing/DrawingGestureRecognizer.swift new file mode 100644 index 00000000..f7cbc6a2 --- /dev/null +++ b/Project/Reazy/Views/PDF/Drawing/DrawingGestureRecognizer.swift @@ -0,0 +1,53 @@ +// +// DrawingGestureRecognizer.swift +// Reazy +// +// Created by Minjung Lee on 10/30/24. +// + +import UIKit + +protocol DrawingGestureRecognizerDelegate: AnyObject { + func gestureRecognizerBegan(_ location: CGPoint) + func gestureRecognizerMoved(_ location: CGPoint) + func gestureRecognizerEnded(_ location: CGPoint) +} + +// PDF 위에서 일어나는 제스처들을 다루는 부분 +class DrawingGestureRecognizer: UIGestureRecognizer { + weak var drawingDelegate: DrawingGestureRecognizerDelegate? + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + if let touch = touches.first, + touch.type == .pencil, // 시뮬레이터에서 펜슬 없이 테스트할 때는 이 줄 주석 해야함! + let numberOfTouches = event?.allTouches?.count, + numberOfTouches == 1 { + state = .began + + let location = touch.location(in: self.view) + drawingDelegate?.gestureRecognizerBegan(location) + } else { + state = .failed + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + state = .changed + + guard let location = touches.first?.location(in: self.view) else { return } + drawingDelegate?.gestureRecognizerMoved(location) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let location = touches.first?.location(in: self.view) else { + state = .ended + return + } + drawingDelegate?.gestureRecognizerEnded(location) + state = .ended + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + state = .failed + } +} diff --git a/Project/Reazy/Views/PDF/Drawing/PDFDrawer.swift b/Project/Reazy/Views/PDF/Drawing/PDFDrawer.swift new file mode 100644 index 00000000..dcc1b194 --- /dev/null +++ b/Project/Reazy/Views/PDF/Drawing/PDFDrawer.swift @@ -0,0 +1,208 @@ +// +// PDFDrawer.swift +// Reazy +// +// Created by Minjung Lee on 10/30/24. +// + +import Foundation +import PDFKit + +enum DrawingTool: Int { + case none = 0 + case eraser = 1 + case pencil = 2 + + var width: CGFloat { + switch self { + case .eraser: return 5 + case .pencil: return 1 + default: return 0 + } + } + + var alpha: CGFloat { + switch self { + case .none: return 0 + default: return 1 + } + } +} + +class PDFDrawer { + + weak var pdfView: PDFView! + private var path: UIBezierPath? + private var currentAnnotation: DrawingAnnotation? + private var currentPage: PDFPage? + var color = UIColor(hex: "#5F5CAB") + var drawingTool = DrawingTool.none + private var eraserLayer: CAShapeLayer? = nil + + private func saveAndAddnnotation(_ path: UIBezierPath, on page: PDFPage) { + let finalAnnotation = createFinalAnnotation(path: path, page: page) + page.addAnnotation(finalAnnotation) + } +} + +extension PDFDrawer: DrawingGestureRecognizerDelegate { + // MARK: -제스처 최초 시작 시 한 번 실행되는 함수 + func gestureRecognizerBegan(_ location: CGPoint) { + guard let page = pdfView.page(for: location, nearest: true) else { return } + currentPage = page + + let pageBounds = pdfView.convert(page.bounds(for: pdfView.displayBox), from: page) + + if pageBounds.contains(location) { + let convertedPoint = pdfView.convert(location, to: currentPage!) + path = UIBezierPath() + path?.move(to: convertedPoint) + } + } + + // MARK: -제스처 움직이는 동안 실행되는 함수 + func gestureRecognizerMoved(_ location: CGPoint) { + guard let page = currentPage else { return } + let convertedPoint = pdfView.convert(location, to: page) + let pageBounds = pdfView.convert(page.bounds(for: pdfView.displayBox), from: page) + + if !pageBounds.contains(location) { + completeCurrentPath(on: page) + return + } + + if drawingTool == .eraser { + updateEraserLayer(at: location) + removeAnnotationAtPoint(point: location, page: page) + return + } + + if path == nil { + path = UIBezierPath() + path?.move(to: convertedPoint) + } else { + path?.addLine(to: convertedPoint) + } + drawAnnotation(onPage: page) + } + + private func completeCurrentPath(on page: PDFPage) { + guard let path = path else { return } + let finalAnnotation = createFinalAnnotation(path: path, page: page) + page.addAnnotation(finalAnnotation) + self.path = nil + currentAnnotation = nil + } + + private func updateEraserLayer(at location: CGPoint) { + // 지우개 레이어가 없다면 새로 생성 + if eraserLayer == nil { + eraserLayer = CAShapeLayer() + eraserLayer?.fillColor = UIColor(hex: "#EFEFF8").cgColor + eraserLayer?.strokeColor = UIColor(hex: "#BABCCF").cgColor + eraserLayer?.lineWidth = 1.0 + pdfView.layer.addSublayer(eraserLayer!) + } + + // 확대 축소 비율 고려해서 지우개 모양 만들기 + let scaleFactor = pdfView.scaleFactor + let eraserRadius = 5 * scaleFactor + let eraserCirclePath = UIBezierPath(arcCenter: location, radius: eraserRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true) + eraserLayer?.path = eraserCirclePath.cgPath + } + + // MARK: - 패드에서 제스처 뗄 때 실행되는 함수 + func gestureRecognizerEnded(_ location: CGPoint) { + guard let page = currentPage else { return } + let convertedPoint = pdfView.convert(location, to: page) + + if drawingTool == .eraser { + removeAnnotationAtPoint(point: location, page: page) + eraserLayer?.removeFromSuperlayer() + eraserLayer = nil + return + } + + guard let _ = currentAnnotation else { return } + + path?.addLine(to: convertedPoint) + path?.move(to: convertedPoint) + page.removeAnnotation(currentAnnotation!) + + if drawingTool == .pencil { + let _ = createFinalAnnotation(path: path!, page: page) + } + currentAnnotation = nil + } + + private func createAnnotation(path: UIBezierPath, page: PDFPage) -> DrawingAnnotation { + let border = PDFBorder() + border.lineWidth = drawingTool.width + + let annotation = DrawingAnnotation(bounds: page.bounds(for: pdfView.displayBox), forType: .ink, withProperties: nil) + annotation.color = color.withAlphaComponent(drawingTool.alpha) + annotation.border = border + return annotation + } + + private func drawAnnotation(onPage: PDFPage) { + guard let path = path else { return } + + if currentAnnotation == nil { + currentAnnotation = createAnnotation(path: path, page: onPage) + } + + currentAnnotation?.path = path + forceRedraw(annotation: currentAnnotation!, onPage: onPage) + } + + // MARK: -획을 그리고 배열에 저장하는 함수 + private func createFinalAnnotation(path: UIBezierPath, page: PDFPage) -> PDFAnnotation { + let border = PDFBorder() + border.lineWidth = drawingTool.width + + let bounds = CGRect(x: path.bounds.origin.x - 5, + y: path.bounds.origin.y - 5, + width: path.bounds.size.width + 10, + height: path.bounds.size.height + 10) + let signingPathCentered = UIBezierPath() + signingPathCentered.cgPath = path.cgPath + let _ = signingPathCentered.moveCenter(to: bounds.center) + + let annotation = PDFAnnotation(bounds: bounds, forType: .ink, withProperties: nil) + annotation.color = color.withAlphaComponent(drawingTool.alpha) + annotation.border = border + annotation.add(signingPathCentered) + page.addAnnotation(annotation) + + return annotation + } + + // MARK: - 지우개로 주석 지울 때 실행되는 함수 + private func removeAnnotationAtPoint(point: CGPoint, page: PDFPage) { + let convertedPoint = pdfView.convert(point, to: page) + let scaleFactor = pdfView.scaleFactor + let scaledRadius = 5 / scaleFactor + let hitTestRect = CGRect(x: convertedPoint.x - scaledRadius, y: convertedPoint.y - scaledRadius, width: scaledRadius * 2, height: scaledRadius * 2) + + let annotations = page.annotations.filter { annotation in + annotation.bounds.intersects(hitTestRect) + } + + if (pdfView.document?.index(for: page)) != nil { + for annotation in annotations { + // 하이라이트랑 드로잉만 지우기 + if annotation.type == "Ink" || (annotation.type == "Highlight" && annotation.value(forAnnotationKey: .contents) == nil) { + _ = annotation.bounds + + page.removeAnnotation(annotation) + } + } + } + } + + private func forceRedraw(annotation: PDFAnnotation, onPage: PDFPage) { + onPage.removeAnnotation(annotation) + onPage.addAnnotation(annotation) + } +} diff --git a/Project/Reazy/Views/PDF/Drawing/UIBezierPath+.swift b/Project/Reazy/Views/PDF/Drawing/UIBezierPath+.swift new file mode 100644 index 00000000..62101952 --- /dev/null +++ b/Project/Reazy/Views/PDF/Drawing/UIBezierPath+.swift @@ -0,0 +1,68 @@ +// +// UIBezierPath+.swift +// Reazy +// +// Created by Minjung Lee on 10/30/24. +// + +import UIKit + +extension CGRect{ + var center: CGPoint { + return CGPoint( x: self.size.width/2.0,y: self.size.height/2.0) + } +} +extension CGPoint{ + func vector(to p1:CGPoint) -> CGVector{ + return CGVector(dx: p1.x-self.x, dy: p1.y-self.y) + } +} + +extension UIBezierPath{ + func moveCenter(to:CGPoint) -> Self{ + let bound = self.cgPath.boundingBox + let center = bounds.center + + let zeroedTo = CGPoint(x: to.x-bound.origin.x, y: to.y-bound.origin.y) + let vector = center.vector(to: zeroedTo) + + let _ = offset(to: CGSize(width: vector.dx, height: vector.dy)) + return self + } + + func offset(to offset:CGSize) -> Self{ + let t = CGAffineTransform(translationX: offset.width, y: offset.height) + let _ = applyCentered(transform: t) + return self + } + + func fit(into:CGRect) -> Self{ + let bounds = self.cgPath.boundingBox + + let sw = into.size.width/bounds.width + let sh = into.size.height/bounds.height + let factor = min(sw, max(sh, 0.0)) + + return scale(x: factor, y: factor) + } + + func scale(x:CGFloat, y:CGFloat) -> Self{ + let scale = CGAffineTransform(scaleX: x, y: y) + let _ = applyCentered(transform: scale) + return self + } + + + func applyCentered(transform: @autoclosure () -> CGAffineTransform ) -> Self{ + let bound = self.cgPath.boundingBox + let center = CGPoint(x: bound.midX, y: bound.midY) + var xform = CGAffineTransform.identity + + xform = xform.concatenating(CGAffineTransform(translationX: -center.x, y: -center.y)) + xform = xform.concatenating(transform()) + xform = xform.concatenating( CGAffineTransform(translationX: center.x, y: center.y)) + apply(xform) + + return self + } +} diff --git a/Project/Reazy/Views/PDF/Floating/FloatingSplitView.swift b/Project/Reazy/Views/PDF/Floating/FloatingSplitView.swift new file mode 100644 index 00000000..06f05682 --- /dev/null +++ b/Project/Reazy/Views/PDF/Floating/FloatingSplitView.swift @@ -0,0 +1,173 @@ +// +// FloatingSplitView.swift +// Reazy +// +// Created by 유지수 on 10/30/24. +// + +import SwiftUI +import PDFKit + +struct SplitDocumentDetails { + let documentID: String + let document: PDFDocument + let head: String +} + +struct FloatingSplitView: View { + + @EnvironmentObject var mainPDFViewModel: MainPDFViewModel + @EnvironmentObject var floatingViewModel: FloatingViewModel + + @ObservedObject var observableDocument: ObservableDocument + + let documentID: String + let document: PDFDocument + let head: String + let isFigSelected: Bool + let onSelect: () -> Void + + init(documentID: String, document: PDFDocument, head: String, isFigSelected: Bool, onSelect: @escaping () -> Void) { + self.document = document + _observableDocument = ObservedObject(wrappedValue: ObservableDocument(document: document)) + + self.documentID = documentID + self.head = head + self.isFigSelected = isFigSelected + self.onSelect = onSelect + } + + @State private var isVertical = false + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + ZStack { + HStack(spacing: 0) { + Button(action: { + floatingViewModel.setFloatingDocument(documentID: documentID) + }, label: { + Image(systemName: "rectangle") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.gray600) + }) + .padding(.horizontal, 20) + + Button(action: { + onSelect() + }, label: { + Image(systemName: isVertical ? "arrow.left.arrow.right" : "arrow.up.arrow.down") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.gray600) + }) + + Spacer() + + Button(action: { + floatingViewModel.deselect(documentID: documentID) + }, label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.gray600) + }) + .padding(.trailing, 20) + } + + HStack(spacing: 0) { + Spacer() + Button(action: { + floatingViewModel.moveToPreviousFigure(mainPDFViewModel: mainPDFViewModel, observableDocument: observableDocument) + }, label: { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.gray600) + }) + Text(head) + .reazyFont(.h3) + .foregroundStyle(.gray800) + .padding(.horizontal, 24) + Button(action: { + floatingViewModel.moveToNextFigure(mainPDFViewModel: mainPDFViewModel, observableDocument: observableDocument) + }, label: { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.gray600) + }) + Spacer() + } + } + .padding(.vertical, 10) + + Rectangle() + .frame(height: 1) + .foregroundStyle(.gray300) + + PDFKitView(document: observableDocument.document, isScrollEnabled: true) + .id(observableDocument.document) + .padding(.horizontal, 30) + .padding(.vertical, 14) + + + if isFigSelected { + Rectangle() + .frame(height: 1) + .foregroundStyle(.gray300) + + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(0.. geometry.size.width + } +} diff --git a/Project/Reazy/Views/PDF/Floating/FloatingView.swift b/Project/Reazy/Views/PDF/Floating/FloatingView.swift new file mode 100644 index 00000000..296e35f8 --- /dev/null +++ b/Project/Reazy/Views/PDF/Floating/FloatingView.swift @@ -0,0 +1,93 @@ +// +// FloatingView.swift +// Reazy +// +// Created by 유지수 on 10/20/24. +// + +import SwiftUI +import PDFKit + +struct FloatingView: View { + + let documentID: String + let document: PDFDocument + let head: String + @Binding var isSelected: Bool + @Binding var viewOffset: CGSize + @Binding var viewWidth: CGFloat + + @State private var aspectRatio: CGFloat = 1.0 + @EnvironmentObject var floatingViewModel: FloatingViewModel + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Button(action: { + floatingViewModel.setSplitDocument(documentID: documentID) + }, label: { + Image(systemName: "rectangle.split.2x1") + .font(.system(size: 14)) + .foregroundStyle(.gray600) + }) + + Spacer() + + Text(head) + .reazyFont(.body3) + .foregroundStyle(.gray800) + + Spacer() + + Button(action: { + floatingViewModel.deselect(documentID: documentID) + }, label: { + Image(systemName: "xmark") + .font(.system(size: 14)) + .foregroundStyle(.gray600) + }) + } + .padding(.bottom, 11) + .padding(.horizontal, 16) + .frame(height: 40) + + Divider() + + PDFKitView(document: document, isScrollEnabled: true) + .frame(width: viewWidth - 36, height: (viewWidth - 36) / aspectRatio) + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + .frame(width: viewWidth) + .padding(.vertical, 11) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + Image(systemName: "righttriangle.fill") + .frame(width: 80, height: 80) + .offset(x: 20, y: 20) + .foregroundStyle(.gray600) + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + // 최대 크기 제한 850 + 최소 크기 제한 300 + let newWidth = max(min(viewWidth + value.translation.width, 850), 300) + self.viewWidth = newWidth + } + ) + .padding(.leading, 100) + .padding(.top, 100), + alignment: .bottomTrailing + ) + .offset(viewOffset) + .onAppear { + // PDF의 첫 번째 페이지 크기를 기준으로 비율 결정 + if let page = document.page(at: 0) { + let pageRect = page.bounds(for: .mediaBox) + self.aspectRatio = pageRect.width / pageRect.height + self.viewWidth = pageRect.width + } + } + } +} diff --git a/Project/Reazy/Views/PDF/Floating/FloatingViewModel.swift b/Project/Reazy/Views/PDF/Floating/FloatingViewModel.swift new file mode 100644 index 00000000..024daaad --- /dev/null +++ b/Project/Reazy/Views/PDF/Floating/FloatingViewModel.swift @@ -0,0 +1,162 @@ +// +// FloatingViewModel.swift +// Reazy +// +// Created by 유지수 on 10/29/24. +// + +import SwiftUI +import PDFKit + +class FloatingViewModel: ObservableObject { + @Published var droppedFigures: [(documentID: String, document: PDFDocument, head: String, isSelected: Bool, viewOffset: CGSize, lastOffset: CGSize, viewWidth: CGFloat, isInSplitMode: Bool)] = [] + @Published var topmostIndex: Int? = nil + + @Published var selectedFigureCellID: String? = nil + @Published var selectedFigureIndex: Int = 0 + @Published var splitMode: Bool = false + + func toggleSelection(for documentID: String, document: PDFDocument, head: String) { + if let index = droppedFigures.firstIndex(where: { $0.documentID == documentID }) { + droppedFigures[index].isSelected.toggle() + if droppedFigures[index].isSelected { + topmostIndex = index + } + } else { + droppedFigures.append(( + documentID: documentID, + document: document, + head: head, + isSelected: true, + viewOffset: CGSize(width: 0, height: 0), + lastOffset: CGSize(width: 0, height: 0), + viewWidth: 300, + isInSplitMode: false + )) + topmostIndex = droppedFigures.count - 1 + } + droppedFigures = droppedFigures.map { $0 } + } + + func deselect(documentID: String) { + if let index = droppedFigures.firstIndex(where: { $0.documentID == documentID }) { + droppedFigures[index].isSelected = false + droppedFigures[index].isInSplitMode = false + + if splitMode && selectedFigureCellID == documentID { + splitMode = false + selectedFigureCellID = nil + } + + droppedFigures = droppedFigures.map { $0 } + } + } + + func isFigureSelected(documentID: String) -> Bool { + return droppedFigures.first { $0.documentID == documentID }?.isSelected ?? false + } + + func setSplitDocument(documentID: String) { + DispatchQueue.main.async { + self.selectedFigureCellID = documentID + self.splitMode = true + + if let index = Int(documentID.components(separatedBy: "-").last ?? "") { + self.selectedFigureIndex = index + } + + if let index = self.droppedFigures.firstIndex(where: { $0.documentID == documentID }) { + self.droppedFigures[index].isInSplitMode = true + } + + for i in 0.. SplitDocumentDetails? { + guard splitMode, let selectedID = selectedFigureCellID else { return nil } + if let selectedFigure = droppedFigures.first(where: { $0.documentID == selectedID }) { + return SplitDocumentDetails( + documentID: selectedFigure.documentID, + document: selectedFigure.document, + head: selectedFigure.head + ) + } + return nil + } +} + +class ObservableDocument: ObservableObject { + @Published var document: PDFDocument + + init(document: PDFDocument) { + self.document = document + } + + func updateDocument(to newDocument: PDFDocument) { + document = newDocument + } +} diff --git a/Project/Reazy/Views/PDF/Floating/FloatingViewsContainer.swift b/Project/Reazy/Views/PDF/Floating/FloatingViewsContainer.swift new file mode 100644 index 00000000..f362f08f --- /dev/null +++ b/Project/Reazy/Views/PDF/Floating/FloatingViewsContainer.swift @@ -0,0 +1,79 @@ +// +// FloatingViewsContainer.swift +// Reazy +// +// Created by 유지수 on 10/29/24. +// + +import SwiftUI + +struct FloatingViewsContainer: View { + @EnvironmentObject var floatingViewModel: FloatingViewModel + let geometry: GeometryProxy + + var body: some View { + ForEach(floatingViewModel.droppedFigures.indices, id: \.self) { index in + let droppedFigure = floatingViewModel.droppedFigures[index] + let isTopmost = (floatingViewModel.topmostIndex == index) + + if droppedFigure.isSelected && !droppedFigure.isInSplitMode { + FloatingView( + documentID: droppedFigure.documentID, + document: droppedFigure.document, + head: droppedFigure.head, + isSelected: Binding( + get: { droppedFigure.isSelected }, + set: { newValue in + floatingViewModel.droppedFigures[index].isSelected = newValue + if newValue { + floatingViewModel.topmostIndex = index + } + } + ), + viewOffset: Binding( + get: { floatingViewModel.droppedFigures[index].viewOffset }, + set: { floatingViewModel.droppedFigures[index].viewOffset = $0 } + ), + viewWidth: Binding( + get: { floatingViewModel.droppedFigures[index].viewWidth }, + set: { floatingViewModel.droppedFigures[index].viewWidth = $0 } + ) + ) + .environmentObject(floatingViewModel) + .aspectRatio(contentMode: .fit) + .shadow( + color: Color(hex: "4D4A97").opacity(0.20), + radius: 12, + x: 0, + y: 2) + .padding(4.5) + .zIndex(isTopmost ? 1 : 0) + .gesture( + DragGesture() + .onChanged { value in + let newOffset = CGSize( + width: floatingViewModel.droppedFigures[index].lastOffset.width + value.translation.width, + height: floatingViewModel.droppedFigures[index].lastOffset.height + value.translation.height + ) + + let maxX = geometry.size.width / 2 - floatingViewModel.droppedFigures[index].viewWidth / 2 + 200 + let minX = -(geometry.size.width / 2 - floatingViewModel.droppedFigures[index].viewWidth / 2) - 200 + let maxY = geometry.size.height / 2 - 150 + 200 + let minY = -(geometry.size.height / 2 - 150) - 200 + + floatingViewModel.droppedFigures[index].viewOffset = CGSize( + width: min(max(newOffset.width, minX), maxX), + height: min(max(newOffset.height, minY), maxY) + ) + } + .onEnded { _ in + floatingViewModel.droppedFigures[index].lastOffset = floatingViewModel.droppedFigures[index].viewOffset + } + ) + .onTapGesture { + floatingViewModel.topmostIndex = index + } + } + } + } +} diff --git a/Project/Reazy/Views/PDF/MainPDFView.swift b/Project/Reazy/Views/PDF/MainPDFView.swift new file mode 100644 index 00000000..28fcddf2 --- /dev/null +++ b/Project/Reazy/Views/PDF/MainPDFView.swift @@ -0,0 +1,512 @@ +// +// PDFView.swift +// Reazy +// +// Created by 유지수 on 10/14/24. +// + +import SwiftUI +import PDFKit + +enum LayoutOrientation { + case vertical, horizontal +} + +struct MainPDFView: View { + + @EnvironmentObject var navigationCoordinator: NavigationCoordinator + @EnvironmentObject private var pdfFileManager: PDFFileManager + + + @StateObject public var mainPDFViewModel: MainPDFViewModel + @StateObject private var floatingViewModel: FloatingViewModel = .init() + @StateObject public var commentViewModel: CommentViewModel + + @State private var selectedButton: WriteButton? = nil + @State private var selectedColor: HighlightColors = .yellow + + @State private var selectedIndex: Int = 0 + @State private var isReadModeFirstSelected: Bool = false + + @State private var isFigSelected: Bool = false + @State private var isSearchSelected: Bool = false + @State private var isVertical = false + @State private var isModifyTitlePresented: Bool = false + @State private var titleText: String = "" + + var body: some View { + GeometryReader { geometry in + ZStack { + VStack(spacing: 0) { + Divider() + .foregroundStyle(Color(hex: "CCCEE1")) + + ZStack { + HStack(spacing: 0) { + Button(action: { + if selectedIndex == 1 { + selectedIndex = 0 + } else { + selectedIndex = 1 + } + }) { + RoundedRectangle(cornerRadius: 6) + .frame(width: 26, height: 26) + .foregroundStyle(selectedIndex == 1 ? .primary1 : .clear) + .overlay ( + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .frame(width: 26, height: 26) + + Image(systemName: "list.bullet") + .resizable() + .scaledToFit() + .foregroundStyle(selectedIndex == 1 ? .gray100 : .gray800) + .frame(width: 18) + } + ) + } + .padding(.trailing, 36) + + Button(action: { + if selectedIndex == 2 { + selectedIndex = 0 + } else { + selectedIndex = 2 + } + }) { + RoundedRectangle(cornerRadius: 6) + .frame(width: 26, height: 26) + .foregroundStyle(selectedIndex == 2 ? .primary1 : .clear) + .overlay ( + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .frame(width: 26, height: 26) + + Image(systemName: "rectangle.grid.1x2") + .resizable() + .scaledToFit() + .foregroundStyle(selectedIndex == 2 ? .gray100 : .gray800) + .frame(width: 18) + } + ) + } + + Spacer() + + Button(action: { + isFigSelected.toggle() + }) { + RoundedRectangle(cornerRadius: 6) + .frame(width: 26, height: 26) + // MARK: - 부리꺼 : 색상 적용 필요 + .foregroundStyle(isFigSelected ? Color(hex: "5F5DAA") : .clear) + .overlay ( + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .frame(width: 26, height: 26) + Text("Fig") + .font(.system(size: 14)) + .foregroundStyle(isFigSelected ? .gray100 : .gray800) + } + ) + } + } + .padding(.vertical, 5) + .padding(.horizontal, 22) + .background(.primary3) + + HStack(spacing: 0) { + Spacer() + + ForEach(WriteButton.allCases, id: \.self) { btn in + // 조건부 Padding값 조정 + let trailingPadding: CGFloat = { + if selectedButton == .highlight && btn == .highlight { + return .zero + } else if btn == .translate { + return .zero + } else { + return 32 + } + }() + + // [Comment], [Highlight], [Pencil], [Eraser], [Translate] 버튼 + WriteViewButton(button: $selectedButton, HighlightColors: $selectedColor, buttonOwner: btn) { + // MARK: - 작성 관련 버튼 action 입력 + /// 위의 다섯 개 버튼의 action 로직은 이곳에 입력해 주세요 + if selectedButton == btn { + selectedButton = nil + mainPDFViewModel.toolMode = .none + } else { + selectedButton = btn + } + + switch selectedButton { + case .translate: + NotificationCenter.default.post(name: .PDFViewSelectionChanged, object: nil) + mainPDFViewModel.toolMode = .translate + + case .pencil: + mainPDFViewModel.toolMode = .pencil + + case .eraser: + mainPDFViewModel.toolMode = .eraser + + case .highlight: + mainPDFViewModel.toolMode = .highlight + + case .comment: + mainPDFViewModel.toolMode = .comment + + default: + // 전체 비활성화 + mainPDFViewModel.toolMode = .none + } + } + .padding(.trailing, trailingPadding) + + // Highlight 버튼이 선택될 경우 색상을 선택 + if selectedButton == .highlight && btn == .highlight { + highlightColorSelector() + } + } + + Spacer() + } + .background(.clear) + + } + + Divider() + .foregroundStyle(Color(hex: "CCCEE1")) + + GeometryReader { geometry in + ZStack { + ZStack { + if isVertical { + splitLayout(for: .vertical) + } else { + splitLayout(for: .horizontal) + } + } + + HStack(spacing: 0){ + if selectedIndex == 1 { + TableView() + .environmentObject(mainPDFViewModel) + .background(.white) + .frame(width: geometry.size.width * 0.22) + + Rectangle() + .frame(width: 1) + .foregroundStyle(Color(hex: "CCCEE1")) + } else if selectedIndex == 2 { + PageView() + .environmentObject(mainPDFViewModel) + .background(.white) + .frame(width: geometry.size.width * 0.22) + + Rectangle() + .frame(width: 1) + .foregroundStyle(Color(hex: "CCCEE1")) + } else { + EmptyView() + } + + Spacer() + + if isFigSelected && !floatingViewModel.splitMode { + Rectangle() + .frame(width: 1) + .foregroundStyle(Color(hex: "CCCEE1")) + + FigureView(onSelect: { documentID, document, head in + floatingViewModel.toggleSelection(for: documentID, document: document, head: head) + }) + .environmentObject(mainPDFViewModel) + .environmentObject(floatingViewModel) + .background(.white) + .frame(width: geometry.size.width * 0.22) + } + } + } + .ignoresSafeArea() + } + } + .customNavigationBar( + centerView: { + HStack(spacing: 5) { + Text(mainPDFViewModel.paperInfo.title) + .reazyFont(.h3) + .foregroundStyle(.gray800) + .lineLimit(1) + + Button { + self.isModifyTitlePresented.toggle() + } label: { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .frame(width: 26, height: 26) + + Image(systemName: "chevron.down") + .font(.system(size: 12)) + .foregroundStyle(.gray800) + } + } + .padding(.leading, 0) + .popover(isPresented: $isModifyTitlePresented) { + ZStack { + Color.gray200 + .scaleEffect(1.5) + + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(.gray100) + .frame(width: 400, height: 52) + + RoundedRectangle(cornerRadius: 12) + .stroke(lineWidth: 1) + .foregroundStyle(.gray400) + .frame(width: 400, height: 52) + + HStack(spacing: 0) { + TextField("제목을 입력하세요", text: $titleText) + .reazyFont(.body2) + .foregroundStyle(.gray900) + .padding(EdgeInsets(top: 18, leading: 18, bottom: 18, trailing: 0)) + + Button { + self.titleText.removeAll() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.gray600) + } + .padding(.trailing, 8) + + } + .frame(width: 400, height: 52) + + } + .padding(.vertical, 15) + .padding(.horizontal, 15) + } + .onAppear { + self.titleText = mainPDFViewModel.paperInfo.title + } + .onDisappear { + if !self.titleText.isEmpty { + let id = self.mainPDFViewModel.paperInfo.id + self.pdfFileManager.updateTitle(at: id, title: self.titleText) + self.mainPDFViewModel.paperInfo.title = self.titleText + } + } + } + + } + .frame(width: isVertical ? 383 : 567) + + }, + leftView: { + HStack(spacing: 0) { + Button(action: { + navigationCoordinator.pop() + }) { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .frame(width: 26, height: 26) + + Image(systemName: "chevron.left") + .foregroundStyle(.gray800) + .font(.system(size: 14)) + } + } + .padding(.trailing, 10) + + Button(action: { + self.isSearchSelected.toggle() + }) { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .frame(width: 26, height: 26) + + Image(systemName: "magnifyingglass") + .foregroundStyle(.gray800) + .font(.system(size: 16)) + } + } + } + }, + rightView: { + EmptyView() + } + ) + .overlay { + OverlaySearchView(isSearchSelected: self.$isSearchSelected) + .environmentObject(mainPDFViewModel) + .animation(.spring(.bouncy), value: self.isSearchSelected) + } + + // MARK: - Floating 뷰 + FloatingViewsContainer(geometry: geometry) + .environmentObject(floatingViewModel) + } + .onAppear { + updateOrientation(with: geometry) + } + .onDisappear { + mainPDFViewModel.savePDF(pdfView: mainPDFViewModel.pdfDrawer.pdfView) + // TODO: - [브리] commentViewModel에 있는 comments랑 buttonGroup 배열 두 개 저장하는 거 여기서 + } + .onChange(of: geometry.size) { + updateOrientation(with: geometry) + } + .onChange(of: self.mainPDFViewModel.figureStatus) { _, newValue in + // 다운로드가 완료된 경우 isFigureSaved 값 변경 + if newValue == .complete { + let id = self.mainPDFViewModel.paperInfo.id + self.pdfFileManager.updateIsFigureSaved(at: id, isFigureSaved: true) + } + } + .statusBarHidden() + } + } + + private func splitLayout(for orientation: LayoutOrientation) -> some View { + Group { + if orientation == .vertical { + VStack(spacing: 0) { + layoutContent(for: orientation) + } + } else { + HStack(spacing: 0) { + layoutContent(for: orientation) + } + } + } + } + + @ViewBuilder + private func layoutContent(for orientation: LayoutOrientation) -> some View { + if floatingViewModel.splitMode && !mainPDFViewModel.isPaperViewFirst, + let splitDetails = floatingViewModel.getSplitDocumentDetails() { + FloatingSplitView( + documentID: splitDetails.documentID, + document: splitDetails.document, + head: splitDetails.head, + isFigSelected: isFigSelected, + onSelect: { + withAnimation { + mainPDFViewModel.isPaperViewFirst.toggle() + } + } + ) + .environmentObject(mainPDFViewModel) + .environmentObject(floatingViewModel) + + divider(for: orientation) + } + + ZStack { + OriginalView() + .environmentObject(mainPDFViewModel) + .environmentObject(floatingViewModel) + .environmentObject(commentViewModel) + // 18 미만 버전에서 번역 모드 on 일 때 말풍선 띄우기 + if #unavailable(iOS 18.0) { + if mainPDFViewModel.toolMode == .translate { + BubbleViewOlderVer() + } + } + } + + if floatingViewModel.splitMode && mainPDFViewModel.isPaperViewFirst, + let splitDetails = floatingViewModel.getSplitDocumentDetails() { + divider(for: orientation) + + FloatingSplitView( + documentID: splitDetails.documentID, + document: splitDetails.document, + head: splitDetails.head, + isFigSelected: isFigSelected, + onSelect: { + withAnimation { + mainPDFViewModel.isPaperViewFirst.toggle() + } + } + ) + .environmentObject(mainPDFViewModel) + .environmentObject(floatingViewModel) + } + } + + @ViewBuilder + private func divider(for orientation: LayoutOrientation) -> some View { + if orientation == .vertical { + Rectangle() + .frame(height: 1) + .foregroundStyle(.gray300) + } else { + Rectangle() + .frame(width: 1) + .foregroundStyle(.gray300) + } + } + + // 기기의 방향에 따라 isVertical 상태를 업데이트하는 함수 + private func updateOrientation(with geometry: GeometryProxy) { + isVertical = geometry.size.height > geometry.size.width + } + + @ViewBuilder + private func highlightColorSelector() -> some View { + Rectangle() + .frame(width: 1, height: 19) + .foregroundStyle(.primary4) + .padding(.leading, 24) + .padding(.trailing, 17) + + ForEach(HighlightColors.allCases, id: \.self) { color in + ColorButton(button: $selectedColor, buttonOwner: color) { + // MARK: - 펜 색상 변경 action 입력 + /// 펜 색상을 변경할 경우, 변경된 색상을 입력하는 로직은 여기에 추가 + selectedColor = color + mainPDFViewModel.selectedHighlightColor = color + } + .padding(.trailing, color == .blue ? .zero : 10) + } + + Rectangle() + .frame(width: 1, height: 19) + .foregroundStyle(.primary4) + .padding(.leading, 24) + .padding(.trailing, 17) + } +} + + +/// 검색 뷰 +private struct OverlaySearchView: View { + @Binding var isSearchSelected: Bool + + var body: some View { + if isSearchSelected { + HStack { + VStack(spacing: 0) { + SearchView() + .padding(EdgeInsets(top: 60, leading: 20, bottom: 0, trailing: 0)) + Spacer() + } + Spacer() + } + } + } +} + diff --git a/Project/Reazy/Views/PDF/Menu/FigureCell.swift b/Project/Reazy/Views/PDF/Menu/FigureCell.swift new file mode 100644 index 00000000..cdd74824 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/FigureCell.swift @@ -0,0 +1,95 @@ +// +// FigureCell.swift +// Reazy +// +// Created by 유지수 on 10/15/24. +// + + +import SwiftUI +import PDFKit +import UniformTypeIdentifiers + + +// MARK: - Lucid : FigureView 커스텀 리스트 셀 +struct PDFKitView: UIViewRepresentable { + + let document: PDFDocument + var isScrollEnabled: Bool + + // PDFView 생성 후 반환 + func makeUIView(context: Context) -> PDFView { + let pdfView = PDFView() + + pdfView.autoScales = true // PDF가 뷰에 맞춰서 스케일 조정 + pdfView.document = document + pdfView.translatesAutoresizingMaskIntoConstraints = false + pdfView.displayMode = .singlePageContinuous + pdfView.pageShadowsEnabled = false + pdfView.backgroundColor = .white + + if let scrollView = pdfView.subviews.first(where: { $0 is UIScrollView }) as? UIScrollView { + scrollView.isScrollEnabled = isScrollEnabled + } + + return pdfView + } + + // 업데이트 메서드 (필요에 따라 사용) + func updateUIView(_ uiView: PDFView, context: Context) { + // 스크롤 활성화 상태 업데이트 + if let scrollView = uiView.subviews.first(where: { $0 is UIScrollView }) as? UIScrollView { + scrollView.isScrollEnabled = isScrollEnabled + } + } +} + + +struct FigureCell: View { + + @EnvironmentObject var mainPDFViewModel: MainPDFViewModel + @EnvironmentObject var floatingViewModel: FloatingViewModel + + let index: Int + let onSelect: (String, PDFDocument, String) -> Void + + @State private var aspectRatio: CGFloat = 1.0 + + var body: some View { + VStack(spacing: 0) { + ZStack { + if let document = mainPDFViewModel.setFigureDocument(for: index) { + if let page = document.page(at: 0) { + let pageRect = page.bounds(for: .mediaBox) + let aspectRatio = pageRect.width / pageRect.height + let head = mainPDFViewModel.figureAnnotations[index].head + let documentID = "figure-\(index)" + + PDFKitView(document: document, isScrollEnabled: false) + .edgesIgnoringSafeArea(.all) // 전체 화면에 맞추기 + .padding(8) + .aspectRatio(aspectRatio, contentMode: .fit) + .simultaneousGesture( + TapGesture().onEnded { + if floatingViewModel.selectedFigureCellID != documentID { + onSelect(documentID, document, head) + } + } + ) + + RoundedRectangle(cornerRadius: 8) + .stroke(floatingViewModel.isFigureSelected(documentID: documentID) ? .primary1 : .primary3, lineWidth: floatingViewModel.isFigureSelected(documentID: documentID) ? 1.5 : 1) + } + } else { + Text("pdf 로드 실패 ") + } + } + .padding(.bottom, 10) + + Text(mainPDFViewModel.figureAnnotations[index].head) + .reazyFont(.body3) + .foregroundStyle(.gray800) + } + } +} + diff --git a/Project/Reazy/Views/PDF/Menu/FigureView.swift b/Project/Reazy/Views/PDF/Menu/FigureView.swift new file mode 100644 index 00000000..54ac7167 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/FigureView.swift @@ -0,0 +1,133 @@ +// +// FigureView.swift +// Reazy +// +// Created by 유지수 on 10/15/24. +// + +import SwiftUI +import PDFKit + +// MARK: - Lucid : Figure 뷰 +struct FigureView: View { + + @EnvironmentObject var mainPDFViewModel: MainPDFViewModel + + @State private var scrollToIndex: Int? = nil + let onSelect: (String, PDFDocument, String) -> Void + + var body: some View { + ZStack { + Color.list + VStack(spacing: 0) { + // TODO: 처음 들어오는지 여부 판단 필요 + + switch mainPDFViewModel.figureStatus { + case .networkDisconnection: + VStack(spacing: 12) { + Text("Figure와 Table을 불러오기 위해\n네트워크 연결이 필요합니다.") + .reazyFont(.body3) + .foregroundStyle(.gray600) + + Button { + Task.init { + await mainPDFViewModel.fetchAnnotations() + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 8) + .foregroundStyle(.gray200) + .frame(width: 72, height: 28) + + RoundedRectangle(cornerRadius: 8) + .stroke(lineWidth: 1) + .foregroundStyle(.gray500) + .frame(width: 72, height: 28) + + Text("다시 시도") + .reazyFont(.body3) + .foregroundStyle(.gray600) + } + } + } + case .loading: + ProgressView() + .progressViewStyle(.circular) + .padding(.bottom, 16) + + Text("Figure와 Table을 불러오는 중입니다") + .reazyFont(.body3) + .foregroundStyle(.gray600) + case .empty: + Text("Fig와 Table이 있으면,\n여기에 표시됩니다") + .multilineTextAlignment(.center) + .reazyFont(.body3) + .foregroundStyle(.gray600) + case .complete: + Text("피규어를 꺼내서 창에 띄울 수 있어요") + .reazyFont(.text2) + .foregroundStyle(.gray600) + .padding(.vertical, 24) + + + // ScrollViewReader로 자동 스크롤 구현 + ScrollViewReader { proxy in + List { + ForEach(0.. pageNumber { + scrollToIndex = index + break + } + } + } + } + + enum FigureStatus { + case networkDisconnection + case loading + case empty + case complete + } +} diff --git a/Project/Reazy/Views/PDF/Menu/PageView.swift b/Project/Reazy/Views/PDF/Menu/PageView.swift new file mode 100644 index 00000000..6793cc2f --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/PageView.swift @@ -0,0 +1,21 @@ +// +// PageView.swift +// Reazy +// +// Created by 유지수 on 10/15/24. +// + +import SwiftUI + +// MARK: - 무니꺼(?) : 페이지 뷰 +struct PageView: View { + var body: some View { + VStack(spacing: 0) { + ThumbnailView() + } + } +} + +#Preview { + PageView() +} diff --git a/Project/Reazy/Views/PDF/Menu/Search/Components/RoundedCornerTriangle.swift b/Project/Reazy/Views/PDF/Menu/Search/Components/RoundedCornerTriangle.swift new file mode 100644 index 00000000..4b4ce1e9 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/Search/Components/RoundedCornerTriangle.swift @@ -0,0 +1,43 @@ +// +// SearchBoxView.swift +// Reazy +// +// Created by 문인범 on 10/29/24. +// + +import UIKit + +/** + 코너를 둥글게 한 삼각형 + */ +final class RoundedCornerTriangle: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + + let triangle = CAShapeLayer() + triangle.fillColor = UIColor.gray100.cgColor + triangle.path = createRoundedTriangle(width: 46, height: 30, radius: 4) + triangle.position = .init(x: 51, y: 0) + self.layer.addSublayer(triangle) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func createRoundedTriangle(width: CGFloat, height: CGFloat, radius: CGFloat) -> CGPath { + let point1 = CGPoint(x: -width / 2, y: height / 2) + let point2 = CGPoint(x: 0, y: -height / 2) + let point3 = CGPoint(x: width / 2, y: height / 2) + + let path = CGMutablePath() + path.move(to: .init(x: 0, y: height / 2)) + path.addArc(tangent1End: point1, tangent2End: point2, radius: radius) + path.addArc(tangent1End: point2, tangent2End: point3, radius: radius) + path.addArc(tangent1End: point3, tangent2End: point1, radius: radius) + path.closeSubpath() + + return path + } +} diff --git a/Project/Reazy/Views/PDF/Menu/Search/Components/SearchBoxView.swift b/Project/Reazy/Views/PDF/Menu/Search/Components/SearchBoxView.swift new file mode 100644 index 00000000..83f9fadc --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/Search/Components/SearchBoxView.swift @@ -0,0 +1,48 @@ +// +// SearchBoxView.swift +// Reazy +// +// Created by 문인범 on 10/29/24. +// + +import SwiftUI + + +/** + 검색 창 View + */ +struct SearchBoxView: View { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + RoundedCornerTriangleView() + .frame(width: 46, height: 10) + + RoundedRectangle(cornerRadius: 12) + .frame(width: 252) + .foregroundStyle(.gray100) + .offset(y: -5) + } + .background { + Color.white + .cornerRadius(12) + .shadow(color:Color(hex: "6A6A6A").opacity(0.1), radius: 16) + .padding(.vertical, 5) + } + } +} + + +private struct RoundedCornerTriangleView: UIViewRepresentable { + func makeUIView(context: Context) -> RoundedCornerTriangle { + .init() + } + + func updateUIView(_ uiView: RoundedCornerTriangle, context: Context) { + + } +} + + +#Preview { + SearchBoxView() +} diff --git a/Project/Reazy/Views/PDF/Menu/Search/Components/SearchListCell.swift b/Project/Reazy/Views/PDF/Menu/Search/Components/SearchListCell.swift new file mode 100644 index 00000000..f9dcd531 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/Search/Components/SearchListCell.swift @@ -0,0 +1,38 @@ +// +// SearchListCell.swift +// Reazy +// +// Created by 문인범 on 10/29/24. +// + +import SwiftUI + + +/** + 검색 결과 Cell + */ +struct SearchListCell: View { + let result: SearchViewModel.SearchResult + + var body: some View { + HStack { + Text(result.text) + .multilineTextAlignment(.leading) + .lineLimit(3) + .padding(12) + + Spacer() + } + .frame(width: 232) + } +} + +#Preview { + let sample = SearchViewModel.SearchResult( + text: "sample입 fpl fasdjf10 fdsfffffff fvbas -0123rj e입니다", + page: 1, + selection: .init()) + + SearchListCell(result: sample) + .frame(width: 300) +} diff --git a/Project/Reazy/Views/PDF/Menu/Search/SearchView.swift b/Project/Reazy/Views/PDF/Menu/Search/SearchView.swift new file mode 100644 index 00000000..0aa5bbe3 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/Search/SearchView.swift @@ -0,0 +1,301 @@ +// +// SearchView.swift +// Reazy +// +// Created by 문인범 on 10/29/24. +// + +import SwiftUI +import PDFKit + + +/** + 검색 결과 보여주는 View + */ +struct SearchView: View { + @EnvironmentObject var mainViewModel: MainPDFViewModel + + @StateObject private var viewModel: SearchViewModel = .init() + + @State private var searchTimer: Timer? + @State private var selectedIndex: Int? + @State private var isTapGesture: Bool = false + @State private var isSearchViewHidden: Bool = false + + @FocusState private var focus: Bool + + let publisher = NotificationCenter.default.publisher(for: .isSearchViewHidden) + + var body: some View { + VStack { + ZStack { + SearchBoxView() + + VStack { + SearchTextFieldView( + viewModel: viewModel, + isSearchViewHidden: $isSearchViewHidden, + focus: $focus) + .padding(.bottom, isSearchViewHidden ? 21 : 0) + + if !self.isSearchViewHidden { + if !viewModel.searchText.isEmpty && !viewModel.searchResults.isEmpty { + SearchTopView( + viewModel: viewModel, + isTapGesture: $isTapGesture, + selectedIndex: $selectedIndex, + focus: $focus) + } + + + if viewModel.isNoMatchTextVisible { + Spacer() + Text("일치하는 결과 없음") + .font(.custom(ReazyFontType.pretendardRegularFont, size: 12)) + .foregroundStyle(.gray800) + } + + if viewModel.isLoading { + Spacer() + ProgressView() + .progressViewStyle(.circular) + } + + if !viewModel.searchResults.isEmpty { + SearchListView( + viewModel: viewModel, + isTapGesture: $isTapGesture, + selectedIndex: $selectedIndex, + focus: $focus) + } else { + Spacer() + } + } + + } + } + .frame(width: 252, height: (viewModel.searchText.isEmpty || isSearchViewHidden) ? 79 : nil) + .onChange(of: viewModel.searchText) { + viewModel.isSearched = false + fetchSearchResult() + } + .onChange(of: selectedIndex) { + if viewModel.searchResults.isEmpty { return } + guard let index = self.selectedIndex else { return } + + mainViewModel.searchSelection = viewModel.searchResults[index].selection + mainViewModel.goToPage(at: viewModel.searchResults[index].page) + } + .onAppear { + UITextField.appearance().clearButtonMode = .whileEditing + } + .onReceive(publisher) { a in + if let _ = a.userInfo?["hitted"] as? Bool { + self.isSearchViewHidden = true + } + } + .animation(.spring, value: isSearchViewHidden) + } + .onDisappear { + viewModel.removeAllAnnotations() + } + } + + +} + +/** + 검색 TextField 뷰 + */ +private struct SearchTextFieldView: View { + @ObservedObject var viewModel: SearchViewModel + + @Binding var isSearchViewHidden: Bool + + var focus: FocusState.Binding + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .frame(width: 232, height: 33) + .foregroundStyle(.gray200) + + HStack { + Image(systemName: "magnifyingglass") + .resizable() + .scaledToFit() + .frame(width: 14) + .padding(.leading, 18) + .foregroundStyle(Color(hex: "9092A9")) + + TextField("검색", text: $viewModel.searchText) { + if $0 { + isSearchViewHidden = false + } + } + .padding(.trailing, 10) + .foregroundStyle(.gray800) + .font(.custom(ReazyFontType.pretendardRegularFont, size: 14)) + .focused(focus) + .onAppear { + focus.wrappedValue = true + } + + } + .frame(width: 252, height: 33) + } + .padding(.top, 25) + } +} + +/** + 검색 결과 상단(검색 결과 갯수, 좌 우 버튼) 뷰 + */ +private struct SearchTopView: View { + @EnvironmentObject var mainViewModel: MainPDFViewModel + @ObservedObject var viewModel: SearchViewModel + + @Binding var isTapGesture: Bool + @Binding var selectedIndex: Int? + + var focus: FocusState.Binding + + var body: some View { + HStack { + Text("\(viewModel.searchResults.count)개 일치") + .reazyFont(.text5) + .foregroundStyle(.gray700) + + Spacer() + + Button { + previousResult() + } label: { + Image(systemName: "chevron.left") + .resizable() + .scaledToFit() + .frame(width: 9) + .foregroundStyle(.gray700) + } + .padding(.trailing, 16) + + Button { + nextResult() + } label: { + Image(systemName: "chevron.right") + .resizable() + .scaledToFit() + .frame(width: 9) + .foregroundStyle(.gray700) + } + + } + .padding(12) + } + + private func nextResult() { + self.isTapGesture = false + self.focus.wrappedValue = false + if self.selectedIndex == nil { return } + + let count = self.viewModel.searchResults.count + + if self.selectedIndex! == count - 1 { + self.selectedIndex = 0 + return + } + + self.selectedIndex! += 1 + } + + private func previousResult() { + self.isTapGesture = false + self.focus.wrappedValue = false + if self.selectedIndex == nil { return } + + if self.selectedIndex! == 0 { + self.selectedIndex = self.viewModel.searchResults.count - 1 + return + } + + self.selectedIndex! -= 1 + } +} + +/** + 검색 결과 테이블 뷰 + */ +private struct SearchListView: View { + @EnvironmentObject var mainViewModel: MainPDFViewModel + + @ObservedObject var viewModel: SearchViewModel + + @Binding var isTapGesture: Bool + @Binding var selectedIndex: Int? + + var focus: FocusState.Binding + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + ForEach(Array(zip(0 ..< self.viewModel.searchResults.count, self.viewModel.searchResults)), id: \.0) { index, search in + LazyVStack(spacing: 0) { + SearchListCell(result: search) + .onTapGesture { + self.isTapGesture = true + self.selectedIndex = index + focus.wrappedValue = false + } + .background { + RoundedRectangle(cornerRadius: 8) + .foregroundStyle(.primary2) + .opacity( selectedIndex == index ? 1 : 0) + } + .id(index) + seperator + .padding(.horizontal, 18) + } + } + } + } + .onChange(of: selectedIndex) { + if !isTapGesture { + proxy.scrollTo(selectedIndex) + } + } + } + } + + private var seperator: some View { + Rectangle() + .frame(height: 1) + .foregroundStyle(.gray400) + } +} + +/** + 뷰모델 업데이트 메소드 + */ +extension SearchView { + private func fetchSearchResult() { + if viewModel.searchText.isEmpty { + viewModel.searchResults.removeAll() + viewModel.isSearched = false + viewModel.isLoading = false + return + } + + guard let document = mainViewModel.document else { return } + + if let timer = self.searchTimer { + timer.invalidate() + } + + self.searchTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in + viewModel.removeAllAnnotations() + viewModel.fetchSearchResults(document: document) + self.selectedIndex = 0 + } + } +} diff --git a/Project/Reazy/Views/PDF/Menu/Search/SearchViewModel.swift b/Project/Reazy/Views/PDF/Menu/Search/SearchViewModel.swift new file mode 100644 index 00000000..8695f24f --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/Search/SearchViewModel.swift @@ -0,0 +1,252 @@ +// +// SearchViewModel.swift +// Reazy +// +// Created by 문인범 on 10/29/24. +// + +import Foundation +import PDFKit + + +/** + 검색 결과를 관할하는 ViewModel + */ +final class SearchViewModel: ObservableObject { + @Published public var searchText: String = "" // TextField 텍스트 + @Published public var searchResults = [SearchResult]() // 검색 결과 array + @Published public var isLoading: Bool = false // 검색 중 알려주는 flag + @Published public var isSearched: Bool = false // 검색이 완료되었는지 알려주는 flag + + private var searchAnnotations: [PDFAnnotation] = [] // 하이라이팅을 위한 annotation 배열 + + public var isNoMatchTextVisible: Bool { + !searchText.isEmpty && searchResults.isEmpty && !isLoading && isSearched + } + + /// 검색 결과 구조체 + struct SearchResult: Hashable { + let text: AttributedString // 검색 결과가 포함된 텍스트 + let page: Int // 키워드가 포함된 페이지 인덱스 + let selection: PDFSelection // 선택된 selection + } +} + + +// MARK: - 데이터 Fetch method +extension SearchViewModel { + + /// pdf 검색 메소드 + public func fetchSearchResults(document: PDFDocument) { + // 백그라운드 쓰레드에서 진행 + // 메인 쓰레드에서 진행 시 검색 중 앱 사용 불가 + DispatchQueue.global().async { + DispatchQueue.main.async { // view 업데이트 관련은 메인 쓰레드에서 진행 + self.isLoading = true + } + + guard !self.searchText.isEmpty else { + DispatchQueue.main.async { + self.isLoading = false + } + return + } + + var results = [SearchResult]() + + // 키워드 검색 + let searchSelections = document.findString(self.searchText, withOptions: .caseInsensitive) + + var currentPage = -1 + var currentIndex = -1 + + searchSelections.forEach { selection in + guard let page = selection.pages.first, let pageText = page.string else { return } + + DispatchQueue.main.async{ + self.addAnnotations(document: document, selection: selection) + } + + // 해당 페이지 인덱스 + let pageCount = document.index(for: page) + + if currentPage != pageCount { + currentPage = pageCount + currentIndex = -1 + } + + let textArray = pageText.split { $0 == " " || $0 == "\n"} + + let keyword = selection.string!.lowercased() + + if currentIndex == -1 { + let index = textArray.firstIndex { String($0).lowercased().contains(keyword) }! + + let resultText = self.fetchKeywordContainedString(index: index, textArray: textArray, keyword: keyword) + + currentIndex = index + 1 + + results.append(.init( + text: resultText, + page: pageCount, + selection: selection)) + + } else { + for i in currentIndex ..< textArray.count { + if String(textArray[i]).lowercased().contains(selection.string!.lowercased()) { + currentIndex = i + 1 + + results.append(.init( + text: self.fetchKeywordContainedString(index: i, textArray: textArray, keyword: keyword), + page: pageCount, + selection: selection)) + break + } + } + } + } + + DispatchQueue.main.async { + self.searchResults = results + self.isLoading = false + self.isSearched = true + } + } + } + + /// 해당 키워드가 포함된 문장 앞 뒤로 짤라서 가져오는 메소드 + private func fetchKeywordContainedString(index: Int, textArray: [String.SubSequence], keyword: String) -> AttributedString { + + var resultText: AttributedString = .init() + // TODO: 필요 시 행간 조절 필요 +// let paragraphStyle: NSMutableParagraphStyle = .init() +// paragraphStyle.lineSpacing = -10 + + // 찾은 키워드의 인덱스가 5보다 작을 경우 + // 0-10 까지의 string을 들고옴 + if index < 5 { + for i in 0 ..< 10 { + let text = textArray[i].replacingOccurrences(of: "\n", with: " ") + if i == index { + var attributedText = AttributedString(text) + + let attributes: AttributeContainer = .init([ + .foregroundColor: UIColor.gray800, + .font: UIFont.init(name: ReazyFontType.pretendardRegularFont, size: 12)!, + ]) + + attributedText.setAttributes(attributes) + + let range = attributedText.range(of: keyword, options: .caseInsensitive) + attributedText[range!].font = .custom(ReazyFontType.pretendardBoldFont, size: 12) + + resultText.append(attributedText + " ") + continue + } + + var attributedText = AttributedString(text) + + let attributes: AttributeContainer = .init([ + .foregroundColor: UIColor.gray800, + .font: UIFont.init(name: ReazyFontType.pretendardRegularFont, size: 12)!, + ]) + + attributedText.setAttributes(attributes) + + resultText.append(attributedText + " ") + } + } else if index > textArray.count - 5 { + for i in textArray.count - 10 ..< textArray.count { + let text = textArray[i].replacingOccurrences(of: "\n", with: " ") + + if i == index { + var attributedText = AttributedString(text) + + let attributes: AttributeContainer = .init([ + .foregroundColor: UIColor.gray800, + .font: UIFont.init(name: ReazyFontType.pretendardRegularFont, size: 12)!, + ]) + + attributedText.setAttributes(attributes) + + let range = attributedText.range(of: keyword, options: .caseInsensitive) + attributedText[range!].font = .custom(ReazyFontType.pretendardBoldFont, size: 12) + + resultText.append(attributedText + " ") + continue + } + + var attributedText = AttributedString(text) + + let attributes: AttributeContainer = .init([ + .foregroundColor: UIColor.gray800, + .font: UIFont.init(name: ReazyFontType.pretendardRegularFont, size: 12)!, + ]) + + attributedText.setAttributes(attributes) + + resultText.append(attributedText + " ") + } + } else { + for i in index - 5 ..< index + 5 { + let text = textArray[i].replacingOccurrences(of: "\n", with: " ") + + if i == index { + var attributedText = AttributedString(text) + + let attributes: AttributeContainer = .init([ + .foregroundColor: UIColor.gray800, + .font: UIFont.init(name: ReazyFontType.pretendardRegularFont, size: 12)!, + ]) + + attributedText.setAttributes(attributes) + + let range = attributedText.range(of: keyword, options: .caseInsensitive) + attributedText[range!].font = .custom(ReazyFontType.pretendardBoldFont, size: 12) + + resultText.append(attributedText + " ") + continue + } + + var attributedText = AttributedString(text) + + let attributes: AttributeContainer = .init([ + .foregroundColor: UIColor.gray800, + .font: UIFont.init(name: ReazyFontType.pretendardRegularFont, size: 12)!, + ]) + + attributedText.setAttributes(attributes) + + resultText.append(attributedText + " ") + } + } + + return resultText + } + + /// 찾은 키워드를 pdfview에 하이라이팅 하는 메소드 + private func addAnnotations(document: PDFDocument, selection: PDFSelection) { + guard let page = selection.pages.first else { return } + + selection.selectionsByLine().forEach { select in + let highlight = PDFAnnotation(bounds: selection.bounds(for: page), forType: .highlight, withProperties: nil) + highlight.endLineStyle = .square + highlight.color = .init(hex: "FED366").withAlphaComponent(0.5) + + self.searchAnnotations.append(highlight) + page.addAnnotation(highlight) + } + } + + /// 검색을 종료할 때 하이라이팅을 지우는 메소드 + public func removeAllAnnotations() { + self.searchAnnotations.forEach { annotation in + guard let page = annotation.page else { return } + + page.removeAnnotation(annotation) + } + + self.searchAnnotations.removeAll() + } +} + diff --git a/Project/Reazy/Views/PDF/Menu/TableCell.swift b/Project/Reazy/Views/PDF/Menu/TableCell.swift new file mode 100644 index 00000000..f4ab9125 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/TableCell.swift @@ -0,0 +1,94 @@ +import SwiftUI +import PDFKit + +// MARK: - 쿠로꺼 : TableView 커스텀 리스트 셀 + +struct TableCell: View { + + @EnvironmentObject var viewModel: MainPDFViewModel + @State var item: TableItem + @Binding var selectedID: UUID? + + var body: some View { + VStack(alignment: .leading){ + if item.children.isEmpty { + HStack{ + //들여쓰기 + Spacer().frame(width: CGFloat(22 * item.level), height: 0) + Text(item.table.label ?? "none") + .lineLimit(1) + .reazyFont(.button5) + .foregroundStyle(.gray900) + } + .padding(.leading, 30) + .padding(.trailing, 9) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background{ + RoundedRectangle(cornerRadius: 4) + .foregroundStyle(selectedID == item.id ? Color(.primary2) : Color.clear) + } + .onTapGesture { + onTap() + } + } else { + HStack{ + //들여쓰기 + Spacer().frame(width: CGFloat(22 * item.level), height: 0) + Button(action: { + withAnimation(.smooth(duration: 0.5)) { + item.isExpanded.toggle() + } + }, label: { + if !item.children.isEmpty { + VStack(alignment: .leading){ + Image(systemName: "chevron.forward" ) + .rotationEffect(.degrees(item.isExpanded ? 90 : 0)) + .animation(.smooth, value: item.isExpanded) + .font(.system(size: 11)) + .foregroundStyle(.gray800) + } + } + }) + .padding(.trailing, 8) + + Text(item.table.label ?? "none") + .lineLimit(1) + .reazyFont(.button5) + .foregroundStyle(.gray900) + } + .padding(.trailing, 9) + .padding(.leading, 4) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background{ + RoundedRectangle(cornerRadius: 4) + .foregroundStyle(selectedID == item.id ? Color(.primary2) : Color.clear) + } + .onTapGesture { + onTap() + } + if item.isExpanded { + ForEach(item.children, id: \.id) { item in + TableCell(item: item, selectedID: $selectedID) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } + Spacer().frame(height: 5) + } + } + + private func onTap() { + if selectedID != item.id { + selectedID = item.id + } + if let destination = item.table.destination { + viewModel.selectedDestination = destination + //test + dump(destination) + } else { + print("No destination") + } + } +} diff --git a/Project/Reazy/Views/PDF/Menu/TableList/ThumbnailView.swift b/Project/Reazy/Views/PDF/Menu/TableList/ThumbnailView.swift new file mode 100644 index 00000000..1cf26a9b --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/TableList/ThumbnailView.swift @@ -0,0 +1,23 @@ +// +// ThumbnailView.swift +// Reazy +// +// Created by 문인범 on 10/20/24. +// + +import SwiftUI + +/** + 썸네일 뷰 컨트롤러 -> SwiftUI + */ +struct ThumbnailView: UIViewControllerRepresentable { + @EnvironmentObject var viewModel: MainPDFViewModel + + typealias UIViewControllerType = ThumbnailTableViewController + + func makeUIViewController(context: Context) -> ThumbnailTableViewController { + .init(viewModel: self.viewModel) + } + + func updateUIViewController(_ uiViewController: ThumbnailTableViewController, context: Context) { } +} diff --git a/Project/Reazy/Views/PDF/Menu/TableList/VC/ThumbnailTableViewCell.swift b/Project/Reazy/Views/PDF/Menu/TableList/VC/ThumbnailTableViewCell.swift new file mode 100644 index 00000000..8f4227cd --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/TableList/VC/ThumbnailTableViewCell.swift @@ -0,0 +1,113 @@ +// +// ThumbnailTableViewCell.swift +// Reazy +// +// Created by 문인범 on 10/20/24. +// + +import UIKit + + +/** + 페이지 리스트 TableView + */ +class ThumbnailTableViewCell: UITableViewCell { + + let pageNum: Int + + let thumbnail: UIImage + + lazy var thumbnailView: UIImageView = { + let view = UIImageView() + view.translatesAutoresizingMaskIntoConstraints = false + view.image = thumbnail + view.contentMode = .scaleAspectFit + view.layer.cornerRadius = 4 + view.layer.borderWidth = 1 + view.layer.borderColor = UIColor.primary3.cgColor + view.clipsToBounds = true + return view + }() + + lazy var pageNumLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "\(self.pageNum + 1)" + label.font = .reazyManualFont(.semibold, size: 14) + label.textColor = .init(hex: "9092A9") + label.textAlignment = .center + return label + }() + + init(pageNum: Int, thumbnail: UIImage) { + self.pageNum = pageNum + self.thumbnail = thumbnail + super.init(style: .default, reuseIdentifier: nil) + + setUI() + + NotificationCenter.default.addObserver(self, selector: #selector(isMyCell), name: .didSelectThumbnail, object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func awakeFromNib() { + super.awakeFromNib() + } +} + +extension ThumbnailTableViewCell { + /// UI 초기 설정 + func setUI() { + self.selectionStyle = .none + + self.addSubview(pageNumLabel) + self.addSubview(thumbnailView) + + let viewWidth = UIScreen.main.bounds.width * 0.22 * 0.7 + + let ratio = thumbnail.size.width / thumbnail.size.height + + NSLayoutConstraint.activate([ + thumbnailView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + thumbnailView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + thumbnailView.heightAnchor.constraint(equalToConstant: viewWidth ), + thumbnailView.widthAnchor.constraint(equalToConstant: viewWidth * ratio) + ]) + + NSLayoutConstraint.activate([ + pageNumLabel.trailingAnchor.constraint(equalTo: thumbnailView.leadingAnchor), + pageNumLabel.topAnchor.constraint(equalTo: thumbnailView.topAnchor), + pageNumLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor), + ]) + } + + /// Notification에 따른 Cell UI 수정 + @objc + private func isMyCell(_ notification: Notification) { + guard let obj = notification.userInfo?["num"] as? Int else { return } + if obj == self.pageNum { + selectCell() + } else { + deselectCell() + } + } + + /// Cell 선택된 이미지로 수정 + public func selectCell() { + thumbnailView.layer.borderWidth = 2 + thumbnailView.layer.borderColor = UIColor.primary1.cgColor + pageNumLabel.textColor = .primary1 + pageNumLabel.font = .reazyManualFont(.semibold, size: 14) + } + + /// Cell 미선택된 이미지로 수정 + private func deselectCell() { + thumbnailView.layer.borderWidth = 1 + thumbnailView.layer.borderColor = UIColor.primary3.cgColor + pageNumLabel.textColor = .init(hex: "9092A9") + pageNumLabel.font = .reazyManualFont(.medium, size: 14) + } +} diff --git a/Project/Reazy/Views/PDF/Menu/TableList/VC/ThumbnailTableViewController.swift b/Project/Reazy/Views/PDF/Menu/TableList/VC/ThumbnailTableViewController.swift new file mode 100644 index 00000000..752bc916 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/TableList/VC/ThumbnailTableViewController.swift @@ -0,0 +1,112 @@ +// +// ThumbnailTableViewController.swift +// Reazy +// +// Created by 문인범 on 10/20/24. +// + +import SwiftUI +import UIKit +import Combine + + +/** + 썸네일 ViewController(페이지 리스트) + */ +final class ThumbnailTableViewController: UIViewController { + + let viewModel: MainPDFViewModel + + var cancellables: Set = [] + + init(viewModel: MainPDFViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + + lazy var thumbnailTableView: UITableView = { + let view = UITableView() + view.translatesAutoresizingMaskIntoConstraints = false + view.delegate = self + view.dataSource = self + view.separatorStyle = .none + return view + }() + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUI() + setBinding() + } +} + +// MARK: - UI 초기 설정 +extension ThumbnailTableViewController { + private func setUI() { + self.view.addSubview(self.thumbnailTableView) + NSLayoutConstraint.activate([ + self.thumbnailTableView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.thumbnailTableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.thumbnailTableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.thumbnailTableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + + } + + private func setBinding() { + self.viewModel.$changedPageNumber + .sink { [weak self] num in + NotificationCenter.default.post(name: .didSelectThumbnail, object: self, userInfo: ["num": num]) + self?.thumbnailTableView.scrollToRow(at: .init(row: num, section: 0), at: .top, animated: true) + } + .store(in: &self.cancellables) + + NotificationCenter.default.addObserver(self, selector: #selector(redrawScreen), name: UIDevice.orientationDidChangeNotification, object: nil) + } + + + @objc private func redrawScreen() { + self.thumbnailTableView.reloadData() + } +} + + +// MARK: - UITableView Delegate +extension ThumbnailTableViewController: UITableViewDelegate, UITableViewDataSource { + /// 페이지 리스트 갯수 + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + self.viewModel.thumnailImages.count + } + + /// 들어갈 셀 추가 + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = indexPath.row + + let cell = ThumbnailTableViewCell(pageNum: row, thumbnail: self.viewModel.thumnailImages[row]) + if self.viewModel.changedPageNumber == row { + cell.selectCell() + } + + return cell + } + + /// 셀 높이 + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + UIScreen.main.bounds.width * 0.2 + } + + /// 셀 선택 되었을 때 + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// guard let cell = tableView.cellForRow(at: indexPath) as? ThumbnailTableViewCell else { return } + + NotificationCenter.default.post(name: .didSelectThumbnail, object: self, userInfo: ["num": indexPath.row]) + self.viewModel.changedPageNumber = indexPath.row + self.viewModel.goToPage(at: indexPath.row) + } +} diff --git a/Project/Reazy/Views/PDF/Menu/TableView.swift b/Project/Reazy/Views/PDF/Menu/TableView.swift new file mode 100644 index 00000000..b5c0a379 --- /dev/null +++ b/Project/Reazy/Views/PDF/Menu/TableView.swift @@ -0,0 +1,49 @@ +// +// TableView.swift +// Reazy +// +// Created by 유지수 on 10/15/24. +// + +import SwiftUI +import PDFKit + + +// MARK: - 쿠로꺼 : 목차 뷰 +struct TableView: View { + + @EnvironmentObject var mainPDFViewModel: MainPDFViewModel + + @State var tableViewModel: TableViewModel = .init() + @State var selectedID: UUID? = nil + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + if tableViewModel.tableItems.isEmpty { + Text("개요가 있으면,\n여기에 표시됩니다") + .reazyFont(.body3) + .foregroundStyle(.gray600) + .padding(.top, 302) + } else { + ForEach(tableViewModel.tableItems) { item in + TableCell(item: item, selectedID: $selectedID) + } + } + Spacer() + } + .padding(.vertical, 16) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity) + } + .onAppear { + if let document = mainPDFViewModel.document{ + tableViewModel.tableItems = tableViewModel.extractToc(from: document) + } else { + tableViewModel.tableItems = [] + } + } + } +} + + diff --git a/Project/Reazy/Views/PDF/PDFViewer/ConcentrateView.swift b/Project/Reazy/Views/PDF/PDFViewer/ConcentrateView.swift new file mode 100644 index 00000000..2d74862f --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/ConcentrateView.swift @@ -0,0 +1,21 @@ +// +// ConcentrateView.swift +// Reazy +// +// Created by 유지수 on 10/14/24. +// + +import SwiftUI + +// MARK: - 무니꺼 : 집중 모드 원문 뷰 +struct ConcentrateView: View { + var body: some View { + VStack(spacing: 0) { + ConcentrateViewControllerRepresent() + } + } +} + +#Preview { + ConcentrateView() +} diff --git a/Project/Reazy/Views/PDF/PDFViewer/OriginalView.swift b/Project/Reazy/Views/PDF/PDFViewer/OriginalView.swift new file mode 100644 index 00000000..50530f06 --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/OriginalView.swift @@ -0,0 +1,94 @@ +// +// OriginalView.swift +// Reazy +// +// Created by 유지수 on 10/14/24. +// + +import SwiftUI +import PDFKit +import Combine + +// MARK: - 무니꺼 : 원문 모드 뷰 +struct OriginalView: View { + @EnvironmentObject private var viewModel: MainPDFViewModel + @EnvironmentObject private var floatingViewModel: FloatingViewModel + @EnvironmentObject var commentViewModel: CommentViewModel + + @State private var keyboardOffset: CGFloat = 0 + + private let publisher = NotificationCenter.default.publisher(for: .isCommentTapped) + private let screenHeight = UIScreen.main.bounds.height + + var body: some View { + GeometryReader { geometry in + ZStack { + VStack(spacing: 0) { + OriginalViewControllerRepresent(commentViewModel: commentViewModel) // PDF 뷰를 표시 + } + .onReceive(publisher) { a in + if let _ = a.userInfo?["hitted"] as? Bool { + viewModel.isCommentTapped = false + viewModel.setHighlight(selectedComments: viewModel.selectedComments, isTapped: viewModel.isCommentTapped) + } + } + .onTapGesture { + // 터치 시 말풍선 뷰를 숨기는 처리 추가 + viewModel.updateBubbleView(selectedText: "", bubblePosition: .zero) + } + // 번역에 사용되는 말풍선뷰 + if viewModel.toolMode == .translate { + if #available(iOS 18.0, *) { + if viewModel.isBubbleViewVisible { + BubbleView(selectedText: $viewModel.selectedText, bubblePosition: $viewModel.bubbleViewPosition, isPaperViewFirst: $viewModel.isPaperViewFirst) + .environmentObject(floatingViewModel) + .environmentObject(viewModel) + } + } + } + if viewModel.isCommentVisible == true || commentViewModel.isEditMode { + CommentGroupView(viewModel: commentViewModel, changedSelection: viewModel.commentSelection ?? PDFSelection()) + .position(viewModel.isCommentTapped || commentViewModel.isEditMode ? commentViewModel.commentPosition : viewModel.commentInputPosition) + } + } + .offset(y: -keyboardOffset) + .onAppear { + + let screenHeight = geometry.size.height + + // 키보드 Notification 설정 + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in + if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + withAnimation { + keyboardOffset = calculateOffset(for: viewModel.commentInputPosition, keyboardFrame: keyboardFrame, screenHeight: screenHeight) + } + } + } + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in + withAnimation { + keyboardOffset = 0 + } + } + } + .animation(.smooth(duration: 0.3), value: viewModel.commentInputPosition) + .animation(.smooth(duration: 0.1), value: viewModel.isCommentTapped) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onChange(of: viewModel.selectedText) { _, newValue in + viewModel.updateBubbleView(selectedText: newValue, bubblePosition: viewModel.bubbleViewPosition) + } + } + } + // 키보드 offset 계산 + private func calculateOffset(for position: CGPoint, keyboardFrame: CGRect, screenHeight: CGFloat) -> CGFloat { + let keyboardTopY = screenHeight - keyboardFrame.height + let margin: CGFloat = 100 // 여유 공간 + + // 키보드에 가려질 경우 + if position.y + 50 > keyboardTopY { + return (position.y - keyboardTopY) + margin + } else { + return 0 // 키보드에 안 가려짐 + } + } +} + diff --git a/Project/Reazy/Views/PDF/PDFViewer/VC/ConcentrateViewController.swift b/Project/Reazy/Views/PDF/PDFViewer/VC/ConcentrateViewController.swift new file mode 100644 index 00000000..fbff9bfe --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/VC/ConcentrateViewController.swift @@ -0,0 +1,143 @@ +// +// ContentrateViewController.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import UIKit +import PDFKit +import Combine + +final class ConcentrateViewController: UIViewController { + + let viewModel: MainPDFViewModel + + var cancellables: Set = [] + + var isPageDestinationWorking: Bool = false + + private var isHandlingPageChange = false + + override func viewDidLoad() { + super.viewDidLoad() + + setData() + setUI() + setBinding() + } + + override func viewWillAppear(_ animated: Bool) { + let focusPageNum = self.viewModel.focusAnnotations.firstIndex { $0.page == self.viewModel.changedPageNumber + 1} + + guard let page = self.viewModel.focusDocument?.page(at: focusPageNum ?? 0) else { return } + + self.pdfView.go(to: page) + } + + lazy var pdfView: PDFView = { + let view = PDFView() + view.translatesAutoresizingMaskIntoConstraints = false + view.displayMode = .singlePageContinuous + view.displayDirection = .vertical + view.pageShadowsEnabled = false + view.pageBreakMargins = .init(top: 20, left: 0, bottom: 0, right: 0) + view.autoScales = false + view.subviews.first!.backgroundColor = .white + return view + }() + + + + init(viewModel: MainPDFViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - 초기 세팅 +extension ConcentrateViewController { + /// filter 된 document 불러오기 + private func setData() { + if self.viewModel.focusAnnotations.isEmpty { + print("empty!!") + return + } + + self.viewModel.setFocusDocument() + self.pdfView.document = self.viewModel.focusDocument + } + + /// UI 설정 + private func setUI() { + self.view.addSubview(self.pdfView) + NSLayoutConstraint.activate([ + self.pdfView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.pdfView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.pdfView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.pdfView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + + // pdf view의 초기 scale 설정 + self.pdfView.scaleFactor = 2 + } + + /// 데이터 바인딩 + private func setBinding() { + self.viewModel.$selectedDestination + .receive(on: DispatchQueue.main) + .sink { [weak self] destination in + self?.isPageDestinationWorking = true + + guard let page = self?.viewModel.findFocusPageNum(destination: destination) else { + self?.isPageDestinationWorking = false + return + } + + self?.pdfView.go(to: page) + self?.isPageDestinationWorking = false + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .PDFViewPageChanged) + .sink { [weak self] _ in + guard let self = self else { return } + + // FloatingSplitView로 인해 위치 변경 시 이벤트 중복 방지 + if self.isHandlingPageChange { return } + + if self.isPageDestinationWorking { return } + + self.isHandlingPageChange = true // 이벤트 처리 시작 + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + defer { self.isHandlingPageChange = false } // 처리 후 플래그 초기화 + + if let currentPage = self.pdfView.currentPage, + let currentPageNum = self.viewModel.focusDocument?.index(for: currentPage), + currentPageNum < self.viewModel.focusAnnotations.count { + + let pageNum = self.viewModel.focusAnnotations[currentPageNum] + + defer { + DispatchQueue.main.async { + self.viewModel.changedPageNumber = pageNum.page + } + } + + DispatchQueue.main.async { + self.viewModel.changedPageNumber = pageNum.page + } + + } else { + print("Warning: currentPageNum is out of focusAnnotations range") + } + } + } + .store(in: &self.cancellables) + } +} diff --git a/Project/Reazy/Views/PDF/PDFViewer/VC/OriginalViewController.swift b/Project/Reazy/Views/PDF/PDFViewer/VC/OriginalViewController.swift new file mode 100644 index 00000000..7650cfcb --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/VC/OriginalViewController.swift @@ -0,0 +1,303 @@ +// +// MainPDFViewController.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import SwiftUI +import UIKit +import PDFKit +import Combine + +/** + 원문 모드 ViewController + */ + +final class OriginalViewController: UIViewController { + + let viewModel: MainPDFViewModel + let commentViewModel: CommentViewModel + + var cancellable: Set = [] + var selectionWorkItem: DispatchWorkItem? + + + let mainPDFView: PDFView = { + let view = PDFView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .gray200 + view.autoScales = false + view.pageShadowsEnabled = false + + // for drawing + view.displayDirection = .vertical + view.usePageViewController(false) + return view + }() + + // for drawing + var shouldUpdatePDFScrollPosition = true + + override func viewDidLoad() { + super.viewDidLoad() + + self.setUI() + self.setData() + self.setGestures() + self.setBinding() + } + + override func viewWillAppear(_ animated: Bool) { + Task.init { + // 집중모드 데이터 패치 + await self.viewModel.fetchAnnotations() + } + + viewModel.goToPage(at: viewModel.changedPageNumber) + } + + init(viewModel: MainPDFViewModel, commentViewModel: CommentViewModel) { + self.viewModel = viewModel + self.commentViewModel = commentViewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - 초기 설정 +extension OriginalViewController { + /// UI 설정 + private func setUI() { + self.view.addSubview(self.mainPDFView) + NSLayoutConstraint.activate([ + self.mainPDFView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.mainPDFView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.mainPDFView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.mainPDFView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + } + + /// ViewModel 설정 + private func setData() { + self.mainPDFView.document = self.viewModel.document + self.commentViewModel.document = self.viewModel.document + + // 썸네일 이미지 패치 + self.viewModel.fetchThumbnailImage() + + // pdfView midX 가져오기 + self.commentViewModel.getPDFCoordinates(pdfView: mainPDFView) + // PDF 문서 로드 완료 후 드로잉 데이터 패치 + DispatchQueue.main.async { + self.viewModel.pdfDrawer.pdfView = self.mainPDFView + // TODO: - Core data에서 배열 load 하는 곳 + self.commentViewModel.loadComments() + } + } + /// 텍스트 선택 해제 + private func cleanTextSelection() { + self.mainPDFView.currentSelection = nil + } + + private func setGestures() { + // 기본 설정: 제스처 추가 + let pdfDrawingGestureRecognizer = DrawingGestureRecognizer() + self.mainPDFView.addGestureRecognizer(pdfDrawingGestureRecognizer) + pdfDrawingGestureRecognizer.drawingDelegate = viewModel.pdfDrawer + viewModel.pdfDrawer.pdfView = self.mainPDFView + viewModel.pdfDrawer.drawingTool = .none + + let gesture = UITapGestureRecognizer(target: self, action: #selector(postScreenTouch)) + gesture.cancelsTouchesInView = false + self.view.addGestureRecognizer(gesture) + + let commentTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleCommentTap(_:))) + commentTapGesture.delegate = self + self.view.addGestureRecognizer(commentTapGesture) + } + + /// 데이터 Binding + private func setBinding() { + // ViewModel toolMode의 변경 감지해서 pencil이랑 eraser일 때만 펜슬 제스처 인식하게 + viewModel.$toolMode + .sink { [weak self] mode in + self?.updateGestureRecognizer(for: mode) + } + .store(in: &cancellable) + + self.viewModel.$selectedDestination + .sink { [weak self] destination in + guard let destination = destination else { return } + guard let page = destination.page else { return } + self?.mainPDFView.go(to: page) + } + .store(in: &self.cancellable) + + self.viewModel.$searchSelection + .sink { [weak self] selection in + self?.mainPDFView.setCurrentSelection(selection, animate: true) + } + .store(in: &self.cancellable) + + // ViewModel toolMode의 변경 감지해서 pencil이랑 eraser일 때만 펜슬 제스처 인식하게 + self.viewModel.$toolMode + .sink { [weak self] mode in + self?.updateGestureRecognizer(for: mode) + } + .store(in: &cancellable) + + NotificationCenter.default.publisher(for: .PDFViewPageChanged) + .sink { [weak self] _ in + guard let page = self?.mainPDFView.currentPage else { return } + guard let num = self?.viewModel.document?.index(for: page) else { return } + DispatchQueue.main.async { + self?.viewModel.changedPageNumber = num + } + } + .store(in: &self.cancellable) + + // 현재 드래그된 텍스트 가져오는 함수 + NotificationCenter.default.publisher(for: .PDFViewSelectionChanged) + .debounce(for: .milliseconds(350), scheduler: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + switch self.viewModel.toolMode { + case .highlight: + DispatchQueue.main.async { + self.viewModel.highlightText(in: self.mainPDFView, with: self.viewModel.selectedHighlightColor) // 하이라이트 기능 + } + case .translate, .comment: + guard let selection = self.mainPDFView.currentSelection else { + // 선택된 텍스트가 없을 때 특정 액션 + self.viewModel.selectedText = "" // 선택된 텍스트 초기화 + self.viewModel.bubbleViewVisible = false // 말풍선 뷰 숨김 + return + } + + self.selectionWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + if let page = selection.pages.first { + + // PDFSelection의 bounds 추출(CGRect) + let bound = selection.bounds(for: page) + let convertedBounds = self.mainPDFView.convert(bound, from: page) + + //comment position 설정 + let commentPosition = CGPoint( + x: convertedBounds.midX, + y: convertedBounds.maxY + 50 + ) + + // 선택된 텍스트 가져오기 + let selectedText = selection.string ?? "" + + // PDFPage의 좌표를 PDFView의 좌표로 변환 + let pagePosition = self.mainPDFView.convert(bound, from: page) + + // PDFView의 좌표를 Screen의 좌표로 변환 + let screenPosition = self.mainPDFView.convert(pagePosition, to: nil) + + DispatchQueue.main.async { + // ViewModel에 선택된 텍스트와 위치 업데이트 + self.viewModel.selectedText = selectedText + self.viewModel.bubbleViewPosition = screenPosition // 위치 업데이트 + self.viewModel.bubbleViewVisible = !selectedText.isEmpty // 텍스트가 있을 때만 보여줌 + + self.viewModel.commentSelection = selection + self.viewModel.commentInputPosition = commentPosition + self.commentViewModel.selectedBounds = bound + } + } + } + + // 텍스트 선택 후 딜레이 + self.selectionWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) + default: + return + } + } + .store(in: &self.cancellable) + + // 저장하면 currentSelection 해제 + self.viewModel.$isCommentSaved + .sink { [weak self] isCommentSaved in + if isCommentSaved { + self?.cleanTextSelection() + } + } + .store(in: &self.cancellable) + } +} + +// MARK: - 탭 제스처 관련 +extension OriginalViewController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + let location = touch.location(in: mainPDFView) + + // 버튼 annotation이 있는 위치인지 확인 + if let page = mainPDFView.page(for: location, nearest: true), + let annotation = page.annotation(at: mainPDFView.convert(location, to: page)), + annotation.widgetFieldType == .button { + return true + } + return false + } + + @objc + func postScreenTouch() { + NotificationCenter.default.post(name: .isSearchViewHidden, object: self, userInfo: ["hitted": true]) + NotificationCenter.default.post(name: .isCommentTapped, object: self, userInfo: ["hitted": false]) + } + + private func updateGestureRecognizer(for mode: ToolMode) { + // 현재 설정된 제스처 인식기를 제거 + if let gestureRecognizers = self.mainPDFView.gestureRecognizers { + for recognizer in gestureRecognizers { + self.mainPDFView.removeGestureRecognizer(recognizer) + } + } + + // toolMode에 따라 제스처 인식기를 추가 + if mode == .pencil || mode == .eraser { + let pdfDrawingGestureRecognizer = DrawingGestureRecognizer() + self.mainPDFView.addGestureRecognizer(pdfDrawingGestureRecognizer) + pdfDrawingGestureRecognizer.drawingDelegate = viewModel.pdfDrawer + viewModel.pdfDrawer.pdfView = self.mainPDFView + viewModel.pdfDrawer.drawingTool = .none + } + } + + // 코멘트 버튼 annotation 제스처 + @objc + func handleCommentTap(_ sender: UITapGestureRecognizer) { + let location = sender.location(in: mainPDFView) + + guard let page = mainPDFView.page(for: location, nearest: true) else { return } + let pageLocation = mainPDFView.convert(location, to: page) + + /// 해당 위치에 Annotation이 있는지 확인 + if let tappedAnnotation = page.annotation(at: pageLocation) { + if let buttonID = tappedAnnotation.contents { + viewModel.selectedComments = commentViewModel.comments.filter { $0.buttonId.uuidString == buttonID } + viewModel.isCommentTapped.toggle() + if viewModel.isCommentTapped { + commentViewModel.setCommentPosition(selectedComments: viewModel.selectedComments, pdfView: mainPDFView) + viewModel.setHighlight(selectedComments: viewModel.selectedComments, isTapped: viewModel.isCommentTapped) + } else { + viewModel.setHighlight(selectedComments: viewModel.selectedComments, isTapped: viewModel.isCommentTapped) + } + } else { + print("No match comment annotation") + } + } + } +} + diff --git a/Project/Reazy/Views/PDF/PDFViewer/VCRepresentable/ConcentrateViewControllerRepresent.swift b/Project/Reazy/Views/PDF/PDFViewer/VCRepresentable/ConcentrateViewControllerRepresent.swift new file mode 100644 index 00000000..a24bf070 --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/VCRepresentable/ConcentrateViewControllerRepresent.swift @@ -0,0 +1,26 @@ +// +// ConcentrateViewControllerRepresent.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import Foundation +import SwiftUI + +/** + 집중 모드 vc SwiftUI 로 변환 + */ +struct ConcentrateViewControllerRepresent: UIViewControllerRepresentable { + typealias UIViewControllerType = ConcentrateViewController + + @EnvironmentObject var viewModel: MainPDFViewModel + + func makeUIViewController(context: Context) -> UIViewControllerType { + ConcentrateViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: ConcentrateViewController, context: Context) { + + } +} diff --git a/Project/Reazy/Views/PDF/PDFViewer/VCRepresentable/OriginalViewRepresentable.swift b/Project/Reazy/Views/PDF/PDFViewer/VCRepresentable/OriginalViewRepresentable.swift new file mode 100644 index 00000000..dc7da6e0 --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/VCRepresentable/OriginalViewRepresentable.swift @@ -0,0 +1,24 @@ +// +// OriginalViewRepresentable.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import SwiftUI + +/// PDFView를 SwiftUI로 사용 위한 변환 구조체 +struct OriginalViewControllerRepresent: UIViewControllerRepresentable { + typealias UIViewControllerType = OriginalViewController + + @EnvironmentObject var mainPDFViewModel: MainPDFViewModel + @StateObject var commentViewModel: CommentViewModel + + func makeUIViewController(context: Context) -> UIViewControllerType { + OriginalViewController(viewModel: mainPDFViewModel, commentViewModel: commentViewModel) + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + + } +} diff --git a/Project/Reazy/Views/PDF/PDFViewer/ViewModels/CommentViewModel.swift b/Project/Reazy/Views/PDF/PDFViewer/ViewModels/CommentViewModel.swift new file mode 100644 index 00000000..78fd874f --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/ViewModels/CommentViewModel.swift @@ -0,0 +1,350 @@ +// +// CommentViewModel.swift +// Reazy +// +// Created by 김예림 on 10/28/24. +// + +import Foundation +import PDFKit +import SwiftUI + +class CommentViewModel: ObservableObject { + + public var paperInfo: PaperInfo + + public var document: PDFDocument? + var pdfCoordinates: CGRect = .zero + + @Published var comments: [Comment] = [] // 전체 코멘트 배열 + @Published var buttonGroup: [ButtonGroup] = [] // 전체 버튼 배열 + var tempCommentArray: [Comment] = [] + + var commentService: CommentDataService + private var buttonGroupService: ButtonGroupDataService + + // 해당 줄의 첫 코멘트를 생성하는 상황에 사용되는 변수 + private var newButtonId = UUID() // 새로 추가되는 버튼의 id + private var isNewButton: Bool = true // 해당 줄의 첫 코멘트인지 확인 + + @Published var commentPosition: CGPoint = .zero /// 저장된 comment.bounds로부터 얻은 position + + //Comment Model + @Published var selectedText: String = "" + @Published var pages: [Int] = [] + @Published var selectedBounds: CGRect = .zero + + @Published var isEditMode = false + @Published var comment: Comment? + + init( + paperInfo: PaperInfo, + commentService: CommentDataService, + buttonGroupService: ButtonGroupDataService + ) { + self.paperInfo = paperInfo + self.commentService = commentService + self.buttonGroupService = buttonGroupService + // MARK: - 기존에 저장된 데이터가 있다면 모델에 저장된 데이터를 추가 + switch commentService.loadCommentData(for: paperInfo.id) { + case .success(let commentList): + tempCommentArray = commentList + case .failure(_): + return + } + + switch buttonGroupService.loadButtonGroup(for: paperInfo.id) { + case .success(let buttonGroupList): + buttonGroup = buttonGroupList + case .failure(_): + return + } + } + + func loadComments() { + for comment in tempCommentArray { + comments.append(comment) + drawUnderline(newComment: comment) + } + + for button in buttonGroup { + drawCommentIcon(button: button) + } + } + + // 새로운 코멘트 추가하는 상황 + func addComment(text: String, selection: PDFSelection) { + if let document = self.document { + getSelectionPages(selection: selection, document: document) + } + if let text = selection.string { + self.selectedText = text + } + isNewButton = true + for group in buttonGroup { + if group.selectedLine == getSelectedLine(selection: selection) { + // 해당 줄에 이미 다른 코멘트가 저장되어있는 경우 + isNewButton = false + // 기존에 존재하는 그룹의 버튼 id를 추가될 코멘트의 id로 지정 + newButtonId = group.id + break + } + } + if isNewButton { + let newGroup = ButtonGroup( + id: UUID(), + page: pages[0], + selectedLine: getSelectedLine(selection: selection), + buttonPosition: getCommentIconPostion(selection: selection) + ) + _ = buttonGroupService.saveButtonGroup(for: paperInfo.id, with: newGroup) + buttonGroup.append(newGroup) + newButtonId = newGroup.id + } + + let newComment = Comment(id: UUID(), + buttonId: newButtonId, + text: text, + selectedText: selectedText, + selectionsByLine: getSelectionsByLine(selection: selection), + pages: pages, + bounds: selectedBounds + ) + + _ = commentService.saveCommentData(for: paperInfo.id, with: newComment) + comments.append(newComment) + drawUnderline(newComment: newComment) + if isNewButton{ + // 방금 추가된 버튼의 아이콘을 그림 + drawCommentIcon(button: buttonGroup.last!) + } + } + + // 코멘트 삭제 + func deleteComment(commentId: UUID) { + let comment = comments.filter { $0.id == commentId }.first! ///전체 코멘트 그룹에서 삭제할 코멘트를 찾음 + let buttonList = comments.filter { $0.buttonId == comment.buttonId } ///전체 코멘트 그룹에서 삭제할 코멘트와 같은 버튼 id를 공유하는 코멘트들 리스트 찾음 + let currentButtonId = comment.buttonId // 삭제할 코멘트의 버튼 id를 변수에 담음 + _ = commentService.deleteCommentData(for: paperInfo.id, id: commentId) + comments.removeAll(where: { $0.id == commentId }) //전체 코멘트 그룹에서 코멘트 삭제 + + if let document = self.document { + guard let page = convertToPDFPage(pageIndex: comment.pages, document: document).first else { return } + for annotation in page.annotations { + if let annotationID = annotation.value(forAnnotationKey: .contents) as? String { + + // 마지막 버튼이었을 경우 버튼 아이콘, 밑줄 삭제 + if buttonList.count == 1 { + if annotationID == comment.buttonId.uuidString || annotationID == comment.id.uuidString { + _ = buttonGroupService.deleteButtonGroup(for: paperInfo.id, id: currentButtonId) + buttonGroup.removeAll(where: { $0.id == currentButtonId}) + page.removeAnnotation(annotation) + } + } else if buttonList.count > 1, annotationID == comment.id.uuidString { + // 버튼에 연결된 남은 코멘트 존재하면 버튼은 두고 밑줄만 삭제 + page.removeAnnotation(annotation) + } + } + } + } + } +} + +//MARK: - 초기세팅을 위한 메서드 +extension CommentViewModel { + + // 저장할 라인 별 selection + private func getSelectionsByLine(selection: PDFSelection) -> [selectionByLine] { + var selections: [selectionByLine] = [] + + let lineSelections = selection.selectionsByLine() + + for lineSelection in lineSelections { + if let page = lineSelection.pages.first { + let bounds = lineSelection.bounds(for: page) + let pageIndex = document?.index(for: page) ?? -1 + selections.append(selectionByLine(page: pageIndex, bounds: bounds)) + } + } + return selections + } + + // selection이 위치하는 Line의 bounds값 + private func getSelectedLine(selection: PDFSelection) -> CGRect{ + var selectedLine: CGRect = .zero + let lineSelection = selection.selectionsByLine() + if let firstLineSelection = lineSelection.first { + + /// 배열 중 첫 번째 selection만 가져오기 + guard let page = firstLineSelection.pages.first else { return .zero} + let bounds = firstLineSelection.bounds(for: page) + + let centerX = bounds.origin.x + bounds.width / 2 + let centerY = bounds.origin.y + bounds.height / 2 + let centerPoint = CGPoint(x: centerX, y: centerY) + + if let line = page.selectionForLine(at: centerPoint) { + let lineBounds = line.bounds(for: page) + selectedLine = lineBounds + } + } + return selectedLine + } + + // 저장할 comment의 position 값 세팅 + func setCommentPosition(selectedComments: [Comment], pdfView: PDFView) { + let buttonId = selectedComments.first?.buttonId + let commentId = selectedComments.first?.id + + guard let boundForComments = buttonGroup.filter({$0.id == buttonId}).first?.selectedLine else { return } + guard let boundForOneComment = comments.filter ({ $0.id == commentId}).first?.bounds else { return } + + if let document = self.document { + guard let page = convertToPDFPage(pageIndex: selectedComments[0].pages, document: document).first else { return } + + var convertedBounds: CGRect = .zero + let offset = CGFloat(selectedComments.count) * 50.0 + + if selectedComments.count == 1 { + convertedBounds = pdfView.convert(boundForOneComment, from: page) + } else { + convertedBounds = pdfView.convert(boundForComments, from: page) + } + + let position = CGPoint( + x: convertedBounds.midX, + y: convertedBounds.maxY + offset + ) + self.commentPosition = position + } + } + + // buttonAnnotation 추가를 위한 pdfView의 좌표 값 + func getPDFCoordinates(pdfView: PDFView) { + guard let currentPage = pdfView.currentPage else { return } + let bounds = currentPage.bounds(for: pdfView.displayBox) + let pdfCoordinates = pdfView.convert(bounds, from: currentPage) + + self.pdfCoordinates = pdfCoordinates + } + + // buttonAnnotation 추가를 위한 아이콘 위치 구하기 + func getCommentIconPostion(selection: PDFSelection) -> CGRect { + + var iconPosition: CGRect = .zero + let lineBounds = getSelectedLine(selection: selection) + + ///PDF 문서의 colum 구분 + let isLeft = lineBounds.maxX < pdfCoordinates.midX + let isRight = lineBounds.minX >= pdfCoordinates.midX + let isAcross = !isLeft && !isRight + + ///colum에 따른 commentIcon 좌표 값 설정 + if isLeft { + iconPosition = CGRect(x: lineBounds.minX - 25, y: lineBounds.minY + 2 , width: 10, height: 10) + } else if isRight || isAcross { + iconPosition = CGRect(x: lineBounds.maxX + 5, y: lineBounds.minY + 2, width: 10, height: 10) + } + return iconPosition + } + + + + // selection 영역이 있는 pages를 [Int]로 반환 + func getSelectionPages(selection: PDFSelection, document: PDFDocument) { + + var pages: [Int] = [] + + for page in selection.pages { + let pageIndex = document.index(for: page) + pages.append(pageIndex) + } + self.pages = pages + } + + // pageIndex를 PDFPage로 변환 + func convertToPDFPage(pageIndex: [Int], document: PDFDocument) -> [PDFPage] { + let PDFPages = pageIndex.compactMap { document.page(at: $0) } + return PDFPages + } +} + + +//MARK: - PDF Anootation관련 +extension CommentViewModel { + + /// 버튼 추가 + func drawCommentIcon(button: ButtonGroup) { + let PDFPage = document?.page(at: button.page) + + let image = UIImage(systemName: "text.bubble") + image?.withTintColor(UIColor.point4, renderingMode: .alwaysOriginal) + + let commentIcon = ImageAnnotation(imageBounds: button.buttonPosition, image: image) + + commentIcon.widgetFieldType = .button + commentIcon.color = .point4 + + /// 버튼에 코멘트 정보 참조 + commentIcon.setValue(button.id.uuidString, forAnnotationKey: .contents) + PDFPage?.addAnnotation(commentIcon) + } + + public class ImageAnnotation: PDFAnnotation { + + private var _image: UIImage? + + // 초기화 시 이미지와 바운드 값을 받음 + public init(imageBounds: CGRect, image: UIImage?) { + self._image = image + super.init(bounds: imageBounds, forType: .stamp, withProperties: nil) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // 이미지를 그릴 때 사용하는 메서드 + override public func draw(with box: PDFDisplayBox, in context: CGContext) { + guard (self._image?.cgImage) != nil else { + return + } + + let tintedImage = self._image?.withTintColor(UIColor.point4, renderingMode: .alwaysTemplate) + + // PDF 페이지에 이미지 그리기 + if let drawingBox = self.page?.bounds(for: box), + let cgTintedImage = tintedImage?.cgImage { + context.draw(cgTintedImage, in: self.bounds.applying(CGAffineTransform( + translationX: (drawingBox.origin.x) * -1.0, + y: (drawingBox.origin.y) * -1.0 + ))) + } + } + } + + /// 밑줄 그리기 + func drawUnderline(newComment: Comment) { + for index in newComment.pages { + guard let page = document?.page(at: index) else { continue } + + for selection in newComment.selectionsByLine { + var bounds = selection.bounds + + /// 밑줄 높이 조정 + let originalBoundsHeight = bounds.size.height + bounds.size.height *= 0.6 + bounds.origin.y += (originalBoundsHeight - bounds.size.height) / 2.5 + + let underline = PDFAnnotation(bounds: bounds, forType: .underline, withProperties: nil) + underline.color = .gray600 + underline.border = PDFBorder() + underline.border?.lineWidth = 1.2 + underline.border?.style = .solid + + underline.setValue(newComment.id.uuidString, forAnnotationKey: .contents) + page.addAnnotation(underline) + } + } + } +} diff --git a/Project/Reazy/Views/PDF/PDFViewer/ViewModels/MainPDFViewModel.swift b/Project/Reazy/Views/PDF/PDFViewer/ViewModels/MainPDFViewModel.swift new file mode 100644 index 00000000..d738f265 --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/ViewModels/MainPDFViewModel.swift @@ -0,0 +1,531 @@ +// +// MainPDFViewModel.swift +// Reazy +// +// Created by 문인범 on 10/17/24. +// + +import PDFKit +import SwiftUI +import Network + + +/** + PDFView 전체 관할 View model + */ +final class MainPDFViewModel: ObservableObject { + + @Published public var figureStatus: FigureView.FigureStatus = .networkDisconnection + + @Published var selectedDestination: PDFDestination? + @Published var searchSelection: PDFSelection? + @Published var changedPageNumber: Int = 0 + @Published var selectedText: String = "" { + didSet { + /// 선택된 텍스트가 변경될 때 추가 작업 + updateBubbleView(selectedText: selectedText, bubblePosition: bubbleViewPosition) + + if isCommentVisible { + updateCommentPosition(at: commentInputPosition) + } + } + } + + @Published var toolMode: ToolMode = .none { + didSet { + updateDrawingTool() + if isCommentVisible { + updateCommentPosition(at: commentInputPosition) + } + } + } + + @Published var isPaperViewFirst: Bool = true + + // BubbleView의 상태와 위치 + @Published var bubbleViewVisible: Bool = false + @Published var bubbleViewPosition: CGRect = .zero + + // 하이라이트 색상 + @Published var selectedHighlightColor: HighlightColors = .yellow + + // Comment + @Published var isCommentTapped: Bool = false + @Published var selectedComments: [Comment] = [] + + @Published var commentSelection: PDFSelection? + @Published var commentInputPosition: CGPoint = .zero + @Published var isCommentSaved: Bool = false + @Published public var paperInfo: PaperInfo + + public var document: PDFDocument? + public var focusDocument: PDFDocument? + + public var focusAnnotations: [FocusAnnotation] = [] + public var figureAnnotations: [FigureAnnotation] = [] // figure 리스트 + + public var thumnailImages: [UIImage] = [] + + // for drawing + public var pdfDrawer: PDFDrawer + + private var figureService: FigureDataService = .shared + + init(paperInfo: PaperInfo) { + self.paperInfo = paperInfo + + var isStale = false + + // TODO: 경로 바뀔 시 모델에 Update 필요 + if let url = try? URL.init(resolvingBookmarkData: paperInfo.url, bookmarkDataIsStale: &isStale), + url.startAccessingSecurityScopedResource() { + self.document = PDFDocument(url: url) + url.stopAccessingSecurityScopedResource() + } else { + if let id = UserDefaults.standard.value(forKey: "sampleId") as? String, + id == paperInfo.id.uuidString { + self.document = PDFDocument(url: Bundle.main.url(forResource: "Reazy Sample Paper", withExtension: "pdf")!) + } + } + self.pdfDrawer = .init() + } + + deinit { + print(#function) + } +} + + +// MARK: - 초기 세팅 메소드 +extension MainPDFViewModel { + public func setPDFDocument(url: URL) { + self.document = PDFDocument(url: url) + } + + public func savePDF(pdfView: PDFView) { + print("savePDF") + guard let document = pdfView.document else { return } + guard let pdfURL = document.documentURL else { + print("PDF URL을 찾을 수 없습니다.") + return + } + + for pageIndex in 0.. Int { + guard let page = destination?.page else { + return -1 + } + + guard let num = self.document?.index(for: page) else { + return -1 + } + + return num + } + + /// 집중 모드에서 페이지 넘버 찾는 메소드 + public func findFocusPageNum(destination: PDFDestination?) -> PDFPage? { + let num = self.findPageNum(destination: destination) + + guard let resultNum = self.focusAnnotations.firstIndex(where:{ $0.page == num + 1 }) else { + return nil + } + + let page = self.focusDocument?.page(at: resultNum) + + return page + } +} + +/** + PageListView 관련 + */ +extension MainPDFViewModel { + /// 현재 document 에서 썸네일 이미지 가져오는 메소드 + public func fetchThumbnailImage() { + var images = [UIImage]() + + guard let document = self.document else { return } + + for i in 0 ..< document.pageCount { + if let page = document.page(at: i) { + + let height = page.bounds(for: .mediaBox).height + let width = page.bounds(for: .mediaBox).width + + let image = page.thumbnail(of: .init(width: width, height: height), for: .mediaBox) + images.append(image) + } + } + + self.thumnailImages = images + } + + /// 페이지 리스트 뷰에서 PDFDestination 생성 메소드 + public func goToPage(at num: Int) { + guard let page = self.document?.page(at: num) else { return } + + let destination = PDFDestination(page: page, at: .zero) + DispatchQueue.main.async { + self.selectedDestination = destination + } + } +} + +/** + Figure 모아보기 뷰 관련 + */ +extension MainPDFViewModel { + /// img 파일에서 크롭 후 pdfDocument 형태로 저장하는 함수 + public func setFigureDocument(for index: Int) -> PDFDocument? { + + // 인덱스가 유효한지 확인 + guard index >= 0 && index < self.figureAnnotations.count else { + print("Invalid index") + return nil + } + + figureAnnotations.sort { $0.page < $1.page } // figure와 table 페이지 순서 정렬 + + let document = PDFDocument() // 새 PDFDocument 생성 + let annotation = self.figureAnnotations[index] // 주어진 인덱스의 annotation 가져오기 + + // 해당 페이지 가져오기 + guard let page = self.document?.page(at: annotation.page - 1)?.copy() as? PDFPage else { + print("Failed to get page") + return nil + } + + page.displaysAnnotations = false + + + let original = page.bounds(for: .mediaBox) // 원본 페이지의 bounds 가져오기 + let croppedRect = original.intersection(annotation.position) // 크롭 영역 계산 (교차 영역) + + page.setBounds(croppedRect, for: .mediaBox) // 페이지의 bounds 설정 + document.insert(page, at: 0) // 새 document에 페이지 추가 + + return document // 생성된 PDFDocument 변환 + } +} + +extension MainPDFViewModel { + public var isBubbleViewVisible: Bool { + get { + self.toolMode == .translate && self.bubbleViewVisible && !self.selectedText.isEmpty + } + } + + + // 선택된 텍스트가 있을 경우 BubbleView를 보이게 하고 위치를 업데이트하는 메서드 + public func updateBubbleView(selectedText: String, bubblePosition: CGRect) { + + // 선택된 텍스트가 있을 경우 BubbleView를 보이게 하고 위치를 업데이트 + if !selectedText.isEmpty { + bubbleViewVisible = true + self.bubbleViewPosition = bubblePosition + } else { + bubbleViewVisible = false + } + } +} + + +extension MainPDFViewModel { + // 하이라이트 기능 + func highlightText(in pdfView: PDFView, with color: HighlightColors) { + // toolMode가 highlight일때 동작 + guard toolMode == .highlight else { return } + + // PDFView 안에서 스크롤 영역 파악 + guard let currentSelection = pdfView.currentSelection else { return } + + // 선택된 텍스트를 줄 단위로 나눔 + let selections = currentSelection.selectionsByLine() + + guard let page = selections.first?.pages.first else { return } + + let highlightColor = color.uiColor + + selections.forEach { selection in + var bounds = selection.bounds(for: page) + let originBoundsHeight = bounds.size.height + bounds.size.height *= 0.6 // bounds 높이 조정하기 + bounds.origin.y += (originBoundsHeight - bounds.size.height) / 2 // 줄인 높인만큼 y축 이동 + + let highlight = PDFAnnotation(bounds: bounds, forType: .highlight, withProperties: nil) + highlight.endLineStyle = .none + highlight.color = highlightColor + + page.addAnnotation(highlight) + } + + pdfView.clearSelection() + } +} + +/** + 코멘트 관련 + */ + +extension MainPDFViewModel { + + public var isCommentVisible: Bool { + return (self.toolMode == .comment && !self.selectedText.isEmpty) || self.isCommentTapped + } + + public func updateCommentPosition(at position: CGPoint) { + self.commentInputPosition = position + } + + /// 하이라이트 + public func setHighlight(selectedComments: [Comment], isTapped: Bool) { + if isTapped { + for comment in selectedComments { + for index in comment.pages { + guard let page = document?.page(at: index) else { continue } + + for selection in comment.selectionsByLine { + var bounds = selection.bounds + + /// 하이라이트 높이 조정 + let originalBoundsHeight = bounds.size.height + bounds.size.height *= 0.6 + bounds.origin.y += (originalBoundsHeight - bounds.height) / 2 + + let highlight = PDFAnnotation(bounds: bounds, forType: .highlight, withProperties: nil) + highlight.color = UIColor.comment + + /// 하이라이트 주석 구별하기 + highlight.setValue("\(comment.buttonId) isHighlight", forAnnotationKey: .contents) + page.addAnnotation(highlight) + } + } + } + } else { + for comment in selectedComments { + /// 하이라이트 제거 + for index in comment.pages { + guard let page = document?.page(at: index) else { continue } + + for annotation in page.annotations { + if let annotationValue = annotation.value(forAnnotationKey: .contents) as? String, + annotationValue == "\(comment.buttonId) isHighlight" { + page.removeAnnotation(annotation) + } + } + } + } + } + } +} + + +enum ToolMode { + case none + case translate + case pencil + case eraser + case highlight + case comment +} + +extension MainPDFViewModel { + private func updateDrawingTool() { + switch toolMode { + case .pencil: + pdfDrawer.drawingTool = .pencil + case .eraser: + pdfDrawer.drawingTool = .eraser + default: + pdfDrawer.drawingTool = .none + } + } +} diff --git a/Project/Reazy/Views/PDF/PDFViewer/ViewModels/TableViewModel.swift b/Project/Reazy/Views/PDF/PDFViewer/ViewModels/TableViewModel.swift new file mode 100644 index 00000000..3dd7d0e6 --- /dev/null +++ b/Project/Reazy/Views/PDF/PDFViewer/ViewModels/TableViewModel.swift @@ -0,0 +1,41 @@ +// +// TableViewModel.swift +// Reazy +// +// Created by 김예림 on 10/18/24. +// + +import Foundation +import PDFKit + +struct TableViewModel { + + public var tableItems: [TableItem] = [] + + func extractToc(from document: PDFDocument) -> [TableItem] { + var tableItems: [TableItem] = [] + + if let outlineRoot = document.outlineRoot { + //outlineRoot의 자식이 하나면 제외하고 fetch 돌리기 + if outlineRoot.numberOfChildren == 1{ + if let child = outlineRoot.child(at: 0) { + fetchToc(table: child, level: 0, parentArray: &tableItems) + } + } else { + fetchToc(table: outlineRoot, level: 0, parentArray: &tableItems) + } + } + + func fetchToc(table: PDFOutline, level: Int, parentArray: inout [TableItem]) { + + for index in 0.. + + + + diff --git a/Project/Resources/Assets.xcassets/icons/Pencil.imageset/Contents.json b/Project/Resources/Assets.xcassets/icons/Pencil.imageset/Contents.json new file mode 100644 index 00000000..08b7cbba --- /dev/null +++ b/Project/Resources/Assets.xcassets/icons/Pencil.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Pencil.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Project/Resources/Assets.xcassets/icons/Pencil.imageset/Pencil.svg b/Project/Resources/Assets.xcassets/icons/Pencil.imageset/Pencil.svg new file mode 100644 index 00000000..67d13952 --- /dev/null +++ b/Project/Resources/Assets.xcassets/icons/Pencil.imageset/Pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Project/Resources/Assets.xcassets/icons/document.imageset/Contents.json b/Project/Resources/Assets.xcassets/icons/document.imageset/Contents.json new file mode 100644 index 00000000..a09b624a --- /dev/null +++ b/Project/Resources/Assets.xcassets/icons/document.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "document.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Project/Resources/Assets.xcassets/icons/document.imageset/document.svg b/Project/Resources/Assets.xcassets/icons/document.imageset/document.svg new file mode 100644 index 00000000..68a78ce6 --- /dev/null +++ b/Project/Resources/Assets.xcassets/icons/document.imageset/document.svg @@ -0,0 +1,3 @@ + + + diff --git a/Project/Resources/Assets.xcassets/icons/empty.imageset/Contents.json b/Project/Resources/Assets.xcassets/icons/empty.imageset/Contents.json new file mode 100644 index 00000000..b2cf31b4 --- /dev/null +++ b/Project/Resources/Assets.xcassets/icons/empty.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "empty.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Project/Resources/Assets.xcassets/icons/empty.imageset/empty.png b/Project/Resources/Assets.xcassets/icons/empty.imageset/empty.png new file mode 100644 index 00000000..fdfe5f54 Binary files /dev/null and b/Project/Resources/Assets.xcassets/icons/empty.imageset/empty.png differ diff --git a/Project/Resources/Assets.xcassets/icons/icon.imageset/Contents.json b/Project/Resources/Assets.xcassets/icons/icon.imageset/Contents.json new file mode 100644 index 00000000..3910ef63 --- /dev/null +++ b/Project/Resources/Assets.xcassets/icons/icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Reazy.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Project/Resources/Assets.xcassets/icons/icon.imageset/Reazy.png b/Project/Resources/Assets.xcassets/icons/icon.imageset/Reazy.png new file mode 100644 index 00000000..14ea1ecb Binary files /dev/null and b/Project/Resources/Assets.xcassets/icons/icon.imageset/Reazy.png differ diff --git a/Project/Resources/Assets.xcassets/test_thumbnail.imageset/Contents.json b/Project/Resources/Assets.xcassets/test_thumbnail.imageset/Contents.json new file mode 100644 index 00000000..13bb6efc --- /dev/null +++ b/Project/Resources/Assets.xcassets/test_thumbnail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "thumbnail.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Project/Resources/Assets.xcassets/test_thumbnail.imageset/thumbnail.png b/Project/Resources/Assets.xcassets/test_thumbnail.imageset/thumbnail.png new file mode 100644 index 00000000..30510b93 Binary files /dev/null and b/Project/Resources/Assets.xcassets/test_thumbnail.imageset/thumbnail.png differ diff --git a/Project/Resources/Fonts/Pretendard-Bold.ttf b/Project/Resources/Fonts/Pretendard-Bold.ttf new file mode 100644 index 00000000..fb07fc65 Binary files /dev/null and b/Project/Resources/Fonts/Pretendard-Bold.ttf differ diff --git a/Project/Resources/Fonts/Pretendard-Medium.ttf b/Project/Resources/Fonts/Pretendard-Medium.ttf new file mode 100644 index 00000000..1db67c68 Binary files /dev/null and b/Project/Resources/Fonts/Pretendard-Medium.ttf differ diff --git a/Project/Resources/Fonts/Pretendard-Regular.ttf b/Project/Resources/Fonts/Pretendard-Regular.ttf new file mode 100644 index 00000000..01147e99 Binary files /dev/null and b/Project/Resources/Fonts/Pretendard-Regular.ttf differ diff --git a/Project/Resources/Fonts/Pretendard-SemiBold.ttf b/Project/Resources/Fonts/Pretendard-SemiBold.ttf new file mode 100644 index 00000000..9f2690f0 Binary files /dev/null and b/Project/Resources/Fonts/Pretendard-SemiBold.ttf differ diff --git a/Project/Resources/PDFs/Reazy Sample Paper.pdf b/Project/Resources/PDFs/Reazy Sample Paper.pdf new file mode 100644 index 00000000..5f68d17c Binary files /dev/null and b/Project/Resources/PDFs/Reazy Sample Paper.pdf differ diff --git a/Project/Resources/PDFs/sample.json b/Project/Resources/PDFs/sample.json new file mode 100644 index 00000000..becc1c65 --- /dev/null +++ b/Project/Resources/PDFs/sample.json @@ -0,0 +1,62 @@ +{ + "div": [ + { + "header": "sample", + "@coords": [ + 1, 2, 3, 4, 5 + ] + } + ], + "fig": [ + { + "id": "fig_1", + "coords": [ + "4,33.52,346.13,527.4,401.83" + ], + "head": "Fig. 1.", + "label": "1", + "figDesc": "sample", + "graphicCoords": null + }, + { + "id": "fig_2", + "coords": [ + "5,34.5,50.35,526.6,401.27" + ], + "head": "Fig. 2.", + "label": "2", + "figDesc": "sample", + "graphicCoords": null + }, + { + "id": "fig_3", + "coords": [ + "6,35.8,52.5,525.6,370.25" + ], + "head": "Fig. 3.", + "label": "3", + "figDesc": "sample", + "graphicCoords": null + }, + { + "id": "fig_4", + "coords": [ + "7,36.82,52.34,524.1,410.42" + ], + "head": "Fig. 4.", + "label": "4", + "figDesc": "sample", + "graphicCoords": null + }, + { + "id": "fig_5", + "coords": [ + "8,35.06,49.5,525.39,383.45" + ], + "head": "Fig. 5.", + "label": "5", + "figDesc": "sample", + "graphicCoords": null + } + ] +} diff --git a/README.md b/README.md index 5541cbe8..af75c220 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# 우리팀의 규칙 +# 우리 팀의 규칙 + + 안녕하세요. 애플 디벨로퍼 아카데미 팀5 Chillin' 입니다❗️ 저희 팀의 건강한 레포지토리 유지를 위해 아래와 같은 규칙을 지켜주세요‼️ diff --git a/Reazy-Info.plist b/Reazy-Info.plist new file mode 100644 index 00000000..bf781e4a --- /dev/null +++ b/Reazy-Info.plist @@ -0,0 +1,15 @@ + + + + + API_URL + $(API_URL) + UIAppFonts + + Pretendard-Bold.ttf + Pretendard-SemiBold.ttf + Pretendard-Medium.ttf + Pretendard-Regular.ttf + + + diff --git a/Reazy.xcodeproj/project.pbxproj b/Reazy.xcodeproj/project.pbxproj new file mode 100644 index 00000000..52334ed9 --- /dev/null +++ b/Reazy.xcodeproj/project.pbxproj @@ -0,0 +1,474 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 2C14588F2CD769FB00B35D92 /* Server.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 2C14588E2CD769FB00B35D92 /* Server.xcconfig */; }; + 2C1469402CE25B7600B35D92 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 2C14693F2CE25B7600B35D92 /* Settings.bundle */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2C1458982CD7D09800B35D92 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2C7AE8822CBCF290004098AF /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2C7AE8892CBCF290004098AF; + remoteInfo = Reazy; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 2C14588E2CD769FB00B35D92 /* Server.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Server.xcconfig; sourceTree = ""; }; + 2C1458942CD7D09800B35D92 /* NetworkUnitTest.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NetworkUnitTest.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2C14693F2CE25B7600B35D92 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; + 2C7AE88A2CBCF290004098AF /* Reazy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reazy.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 6E8311262CC4DCAC00D4F1E6 /* Reazy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Reazy-Info.plist"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 2C1458952CD7D09800B35D92 /* NetworkUnitTest */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = NetworkUnitTest; + sourceTree = ""; + }; + 6E08044B2CBD2B12000AFEEE /* Project */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Project; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2C1458912CD7D09800B35D92 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2C7AE8872CBCF290004098AF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2C7AE8812CBCF290004098AF = { + isa = PBXGroup; + children = ( + 2C14588E2CD769FB00B35D92 /* Server.xcconfig */, + 6E08044B2CBD2B12000AFEEE /* Project */, + 2C1458952CD7D09800B35D92 /* NetworkUnitTest */, + 2C7AE88B2CBCF290004098AF /* Products */, + 6E8311262CC4DCAC00D4F1E6 /* Reazy-Info.plist */, + 2C14693F2CE25B7600B35D92 /* Settings.bundle */, + ); + sourceTree = ""; + }; + 2C7AE88B2CBCF290004098AF /* Products */ = { + isa = PBXGroup; + children = ( + 2C7AE88A2CBCF290004098AF /* Reazy.app */, + 2C1458942CD7D09800B35D92 /* NetworkUnitTest.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2C1458932CD7D09800B35D92 /* NetworkUnitTest */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2C14589A2CD7D09800B35D92 /* Build configuration list for PBXNativeTarget "NetworkUnitTest" */; + buildPhases = ( + 2C1458902CD7D09800B35D92 /* Sources */, + 2C1458912CD7D09800B35D92 /* Frameworks */, + 2C1458922CD7D09800B35D92 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 2C1458992CD7D09800B35D92 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 2C1458952CD7D09800B35D92 /* NetworkUnitTest */, + ); + name = NetworkUnitTest; + packageProductDependencies = ( + ); + productName = NetworkUnitTest; + productReference = 2C1458942CD7D09800B35D92 /* NetworkUnitTest.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 2C7AE8892CBCF290004098AF /* Reazy */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2C7AE8982CBCF291004098AF /* Build configuration list for PBXNativeTarget "Reazy" */; + buildPhases = ( + 2C7AE8862CBCF290004098AF /* Sources */, + 2C7AE8872CBCF290004098AF /* Frameworks */, + 2C7AE8882CBCF290004098AF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 6E08044B2CBD2B12000AFEEE /* Project */, + ); + name = Reazy; + packageProductDependencies = ( + ); + productName = Reazy; + productReference = 2C7AE88A2CBCF290004098AF /* Reazy.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2C7AE8822CBCF290004098AF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 2C1458932CD7D09800B35D92 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 2C7AE8892CBCF290004098AF; + }; + 2C7AE8892CBCF290004098AF = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 2C7AE8852CBCF290004098AF /* Build configuration list for PBXProject "Reazy" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2C7AE8812CBCF290004098AF; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 2C7AE88B2CBCF290004098AF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2C7AE8892CBCF290004098AF /* Reazy */, + 2C1458932CD7D09800B35D92 /* NetworkUnitTest */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2C1458922CD7D09800B35D92 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2C7AE8882CBCF290004098AF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C1469402CE25B7600B35D92 /* Settings.bundle in Resources */, + 2C14588F2CD769FB00B35D92 /* Server.xcconfig in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2C1458902CD7D09800B35D92 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2C7AE8862CBCF290004098AF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2C1458992CD7D09800B35D92 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2C7AE8892CBCF290004098AF /* Reazy */; + targetProxy = 2C1458982CD7D09800B35D92 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2C14589B2CD7D09800B35D92 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = MT99YM3KLX; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "DeveloperAcademy-POSTECH.Chillin.NetworkUnitTest"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Reazy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Reazy"; + }; + name = Debug; + }; + 2C14589C2CD7D09800B35D92 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = MT99YM3KLX; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "DeveloperAcademy-POSTECH.Chillin.NetworkUnitTest"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Reazy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Reazy"; + }; + name = Release; + }; + 2C7AE8962CBCF291004098AF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 2C7AE8972CBCF291004098AF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2C7AE8992CBCF291004098AF /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2C14588E2CD769FB00B35D92 /* Server.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Project/Preview Content\""; + DEVELOPMENT_TEAM = MT99YM3KLX; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Reazy-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.chillin.reazy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 2; + }; + name = Debug; + }; + 2C7AE89A2CBCF291004098AF /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2C14588E2CD769FB00B35D92 /* Server.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Project/Preview Content\""; + DEVELOPMENT_TEAM = MT99YM3KLX; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Reazy-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.chillin.reazy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 2; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2C14589A2CD7D09800B35D92 /* Build configuration list for PBXNativeTarget "NetworkUnitTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2C14589B2CD7D09800B35D92 /* Debug */, + 2C14589C2CD7D09800B35D92 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2C7AE8852CBCF290004098AF /* Build configuration list for PBXProject "Reazy" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2C7AE8962CBCF291004098AF /* Debug */, + 2C7AE8972CBCF291004098AF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2C7AE8982CBCF291004098AF /* Build configuration list for PBXNativeTarget "Reazy" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2C7AE8992CBCF291004098AF /* Debug */, + 2C7AE89A2CBCF291004098AF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 2C7AE8822CBCF290004098AF /* Project object */; +} diff --git a/Reazy.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Reazy.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Reazy.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Reazy.xcodeproj/xcshareddata/xcschemes/Reazy.xcscheme b/Reazy.xcodeproj/xcshareddata/xcschemes/Reazy.xcscheme new file mode 100644 index 00000000..ed13749e --- /dev/null +++ b/Reazy.xcodeproj/xcshareddata/xcschemes/Reazy.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Settings.bundle/Description.plist b/Settings.bundle/Description.plist new file mode 100644 index 00000000..263ff743 --- /dev/null +++ b/Settings.bundle/Description.plist @@ -0,0 +1,216 @@ + + + + + PreferenceSpecifiers + + + Type + PSGroupSpecifier + Title + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2008-2023 GROBID's contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + + diff --git a/Settings.bundle/Root.plist b/Settings.bundle/Root.plist new file mode 100644 index 00000000..754bf548 --- /dev/null +++ b/Settings.bundle/Root.plist @@ -0,0 +1,21 @@ + + + + + StringsTable + Root + PreferenceSpecifiers + + + Type + PSChildPaneSpecifier + Title + Acknowledgements + Key + childpane_identifier + File + Description + + + + diff --git a/Settings.bundle/en.lproj/Root.strings b/Settings.bundle/en.lproj/Root.strings new file mode 100644 index 00000000..8cd87b9d Binary files /dev/null and b/Settings.bundle/en.lproj/Root.strings differ