From bb0272b2faae0de593caef69cefaba8d6a892d31 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 24 Jan 2024 11:31:06 +0100 Subject: [PATCH] HTML/EPUB reader implemented --- .gitignore | 3 +- .gitmodules | 3 + Zotero.xcodeproj/project.pbxproj | 72 ++ .../CreateHtmlEpubAnnotationsDbRequest.swift | 126 +++ Zotero/Models/Defaults.swift | 5 + Zotero/Models/FieldKeys.swift | 28 + Zotero/Scenes/Detail/DetailCoordinator.swift | 18 +- .../HTML:EPUB/HtmlEpubCoordinator.swift | 321 +++++++ .../HTML:EPUB/Models/HtmlEpubAnnotation.swift | 56 ++ .../Models/HtmlEpubReaderAction.swift | 36 + .../Models/HtmlEpubReaderState.swift | 124 +++ .../HTML:EPUB/Models/HtmlEpubSettings.swift | 37 + .../HtmlEpubReaderActionHandler.swift | 877 ++++++++++++++++++ .../HtmlEpubDocumentViewController.swift | 257 +++++ .../Views/HtmlEpubReaderViewController.swift | 566 +++++++++++ .../Views/HtmlEpubSidebarViewController.swift | 432 +++++++++ .../Annotation View/AnnotationView.swift | 67 +- .../Detail/PDF/Views/AnnotationCell.swift | 49 +- .../General/Models/ReaderSettingsState.swift | 10 + 19 files changed, 3064 insertions(+), 23 deletions(-) create mode 100644 Zotero/Controllers/Database/Requests/CreateHtmlEpubAnnotationsDbRequest.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/HtmlEpubCoordinator.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubAnnotation.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderAction.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderState.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubSettings.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/ViewModels/HtmlEpubReaderActionHandler.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubDocumentViewController.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubReaderViewController.swift create mode 100644 Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubSidebarViewController.swift diff --git a/.gitignore b/.gitignore index 6d877d71d..c09731a20 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,5 @@ fastlane/test_output bundled/translators bundled/styles -bundled/locales \ No newline at end of file +bundled/locales +bundled/reader \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 9c75c110b..8bb7139d5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "translators"] path = translators url = https://github.com/zotero/translators.git +[submodule "reader"] + path = reader + url = https://github.com/zotero/reader.git diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 805ee9369..867d2a2cc 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -414,6 +414,7 @@ B31CC57F28646D8E0055C114 /* ManualLookupAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31CC57E28646D8E0055C114 /* ManualLookupAction.swift */; }; B31CC58128646D990055C114 /* ManualLookupState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31CC58028646D990055C114 /* ManualLookupState.swift */; }; B31CC58328646DD20055C114 /* ManualLookupActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31CC58228646DD20055C114 /* ManualLookupActionHandler.swift */; }; + B31CF9292ACED3B5009ED996 /* HtmlEpubSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31CF9282ACED3B5009ED996 /* HtmlEpubSidebarViewController.swift */; }; B31D4A722767840800E22DCC /* BackgroundTaskController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31D4A712767840800E22DCC /* BackgroundTaskController.swift */; }; B31D4A732767841800E22DCC /* BackgroundTaskController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31D4A712767840800E22DCC /* BackgroundTaskController.swift */; }; B31D973C27F5E51100ED3DA2 /* ItemsToolbarDownloadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31D973B27F5E51100ED3DA2 /* ItemsToolbarDownloadProgressView.swift */; }; @@ -597,6 +598,9 @@ B356A3982524A6D5003F1943 /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = B356A3972524A6D5003F1943 /* OHHTTPStubs */; }; B356A39E2524A703003F1943 /* CocoaLumberjackSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B356A39D2524A703003F1943 /* CocoaLumberjackSwift */; }; B356A3A02524A703003F1943 /* CocoaLumberjack in Frameworks */ = {isa = PBXBuildFile; productRef = B356A39F2524A703003F1943 /* CocoaLumberjack */; }; + B3574E3B2AC5AAC6009234BE /* HtmlEpubAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3574E3A2AC5AAC6009234BE /* HtmlEpubAnnotation.swift */; }; + B3574E3D2AC5C049009234BE /* CreateHtmlEpubAnnotationsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3574E3C2AC5C049009234BE /* CreateHtmlEpubAnnotationsDbRequest.swift */; }; + B3574E3F2AC5C53E009234BE /* HtmlEpubCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3574E3E2AC5C53E009234BE /* HtmlEpubCoordinator.swift */; }; B357A289285B706B00E73CA1 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B357A287285B706B00E73CA1 /* ScannerViewController.swift */; }; B357A28A285B706B00E73CA1 /* ScannerViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B357A288285B706B00E73CA1 /* ScannerViewController.xib */; }; B357A28C285B73BD00E73CA1 /* ScannerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B357A28B285B73BD00E73CA1 /* ScannerState.swift */; }; @@ -1048,6 +1052,8 @@ B3E44640248FBD4A007FE8AB /* RRelation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E4463E248FBD2E007FE8AB /* RRelation.swift */; }; B3E44642248FBE9B007FE8AB /* LinkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E44641248FBE9B007FE8AB /* LinkType.swift */; }; B3E44643248FBF12007FE8AB /* LinkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E44641248FBE9B007FE8AB /* LinkType.swift */; }; + B3E67C0C2AA75DC100D7615F /* HtmlEpubReaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E67C0B2AA75DC100D7615F /* HtmlEpubReaderViewController.swift */; }; + B3E67C0E2AA7631D00D7615F /* HtmlEpubDocumentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E67C0D2AA7631D00D7615F /* HtmlEpubDocumentViewController.swift */; }; B3E7C150278348DC00076131 /* BackgroundUploadObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32EBFB9276A89190003897E /* BackgroundUploadObserver.swift */; }; B3E7C151278348F200076131 /* BackgroundUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32EBFB6276A89190003897E /* BackgroundUploadProcessor.swift */; }; B3E8B24F27DA3969001825F8 /* SplitAnnotationsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E8B24E27DA3969001825F8 /* SplitAnnotationsDbRequest.swift */; }; @@ -1141,13 +1147,17 @@ B3F55A1729EED04700A6716E /* ReadFilteredTagsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F55A1629EED04700A6716E /* ReadFilteredTagsDbRequest.swift */; }; B3F55A1929EED4CB00A6716E /* ReadAutomaticTagsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F55A1829EED4CB00A6716E /* ReadAutomaticTagsDbRequest.swift */; }; B3F55A1B29EED4EA00A6716E /* ReadColoredTagsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F55A1A29EED4EA00A6716E /* ReadColoredTagsDbRequest.swift */; }; + B3F6AA362AB3039F005BC22E /* HtmlEpubReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F6AA352AB3039F005BC22E /* HtmlEpubReaderState.swift */; }; + B3F6AA382AB303E8005BC22E /* HtmlEpubReaderActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F6AA372AB303E8005BC22E /* HtmlEpubReaderActionHandler.swift */; }; B3F6AA3A2AB30663005BC22E /* AnnotationTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F6AA392AB30663005BC22E /* AnnotationTool.swift */; }; + B3F6AA3C2AB30CDA005BC22E /* HtmlEpubReaderAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F6AA3B2AB30CDA005BC22E /* HtmlEpubReaderAction.swift */; }; B3F7B0682524A78F00E51377 /* CocoaLumberjack in Frameworks */ = {isa = PBXBuildFile; productRef = B3F7B0672524A78F00E51377 /* CocoaLumberjack */; }; B3F7B06A2524A78F00E51377 /* CocoaLumberjackSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B3F7B0692524A78F00E51377 /* CocoaLumberjackSwift */; }; B3F7B06F2524A8B700E51377 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = B3F7B06E2524A8B700E51377 /* Alamofire */; }; B3F7B0732524A8D500E51377 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B3F7B0722524A8D500E51377 /* KeychainSwift */; }; B3F7B0752524A8D800E51377 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = B3F7B0742524A8D800E51377 /* ZIPFoundation */; }; B3F83808263960FA00E128A6 /* ItemAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F83807263960FA00E128A6 /* ItemAccessory.swift */; }; + B3F9A4B82B04C97A00684030 /* HtmlEpubSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F9A4B72B04C97A00684030 /* HtmlEpubSettings.swift */; }; B3F9A4BD2B04CEC300684030 /* ReaderSettingsSegmentedCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F9A4BA2B04CEC300684030 /* ReaderSettingsSegmentedCellContentView.swift */; }; B3F9A4BE2B04CEC300684030 /* ReaderSettingsSegmentedCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F9A4BB2B04CEC300684030 /* ReaderSettingsSegmentedCell.swift */; }; B3F9A4BF2B04CEC300684030 /* ReaderSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F9A4BC2B04CEC300684030 /* ReaderSettingsViewController.swift */; }; @@ -1479,6 +1489,7 @@ B31CC57E28646D8E0055C114 /* ManualLookupAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualLookupAction.swift; sourceTree = ""; }; B31CC58028646D990055C114 /* ManualLookupState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualLookupState.swift; sourceTree = ""; }; B31CC58228646DD20055C114 /* ManualLookupActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualLookupActionHandler.swift; sourceTree = ""; }; + B31CF9282ACED3B5009ED996 /* HtmlEpubSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubSidebarViewController.swift; sourceTree = ""; }; B31D4A712767840800E22DCC /* BackgroundTaskController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskController.swift; sourceTree = ""; }; B31D973B27F5E51100ED3DA2 /* ItemsToolbarDownloadProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsToolbarDownloadProgressView.swift; sourceTree = ""; }; B31DDA9D27299BD1002CFA05 /* StoreMtimeForAttachmentDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMtimeForAttachmentDbRequest.swift; sourceTree = ""; }; @@ -1620,6 +1631,9 @@ B353F202242E52610062EE24 /* Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; B355B12B2850B6C400BAE2C5 /* TableViewDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDiffableDataSource.swift; sourceTree = ""; }; B355B8FE239951E800E837B9 /* translation */ = {isa = PBXFileReference; lastKnownFileType = folder; path = translation; sourceTree = ""; }; + B3574E3A2AC5AAC6009234BE /* HtmlEpubAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubAnnotation.swift; sourceTree = ""; }; + B3574E3C2AC5C049009234BE /* CreateHtmlEpubAnnotationsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateHtmlEpubAnnotationsDbRequest.swift; sourceTree = ""; }; + B3574E3E2AC5C53E009234BE /* HtmlEpubCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubCoordinator.swift; sourceTree = ""; }; B357A287285B706B00E73CA1 /* ScannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = ""; }; B357A288285B706B00E73CA1 /* ScannerViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ScannerViewController.xib; sourceTree = ""; }; B357A28B285B73BD00E73CA1 /* ScannerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerState.swift; sourceTree = ""; }; @@ -1987,6 +2001,8 @@ B3E4463B248FBBA3007FE8AB /* RLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RLink.swift; sourceTree = ""; }; B3E4463E248FBD2E007FE8AB /* RRelation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRelation.swift; sourceTree = ""; }; B3E44641248FBE9B007FE8AB /* LinkType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkType.swift; sourceTree = ""; }; + B3E67C0B2AA75DC100D7615F /* HtmlEpubReaderViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HtmlEpubReaderViewController.swift; sourceTree = ""; }; + B3E67C0D2AA7631D00D7615F /* HtmlEpubDocumentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubDocumentViewController.swift; sourceTree = ""; }; B3E8B24E27DA3969001825F8 /* SplitAnnotationsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitAnnotationsDbRequest.swift; sourceTree = ""; }; B3E8FDF92714292E00F51458 /* StorageSettingsActionHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageSettingsActionHandler.swift; sourceTree = ""; }; B3E8FDFB2714292E00F51458 /* StorageSettingsState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageSettingsState.swift; sourceTree = ""; }; @@ -2072,8 +2088,12 @@ B3F55A1829EED4CB00A6716E /* ReadAutomaticTagsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadAutomaticTagsDbRequest.swift; sourceTree = ""; }; B3F55A1A29EED4EA00A6716E /* ReadColoredTagsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadColoredTagsDbRequest.swift; sourceTree = ""; }; B3F6415F2A28B21E00A78CB0 /* ci_pre_xcodebuild.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_pre_xcodebuild.sh; sourceTree = ""; }; + B3F6AA352AB3039F005BC22E /* HtmlEpubReaderState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubReaderState.swift; sourceTree = ""; }; + B3F6AA372AB303E8005BC22E /* HtmlEpubReaderActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubReaderActionHandler.swift; sourceTree = ""; }; B3F6AA392AB30663005BC22E /* AnnotationTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationTool.swift; sourceTree = ""; }; + B3F6AA3B2AB30CDA005BC22E /* HtmlEpubReaderAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubReaderAction.swift; sourceTree = ""; }; B3F83807263960FA00E128A6 /* ItemAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemAccessory.swift; sourceTree = ""; }; + B3F9A4B72B04C97A00684030 /* HtmlEpubSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlEpubSettings.swift; sourceTree = ""; }; B3F9A4BA2B04CEC300684030 /* ReaderSettingsSegmentedCellContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSettingsSegmentedCellContentView.swift; sourceTree = ""; }; B3F9A4BB2B04CEC300684030 /* ReaderSettingsSegmentedCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSettingsSegmentedCell.swift; sourceTree = ""; }; B3F9A4BC2B04CEC300684030 /* ReaderSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSettingsViewController.swift; sourceTree = ""; }; @@ -2302,6 +2322,7 @@ B39D33702400150B00EF2ACB /* CreateAttachmentsDbRequest.swift */, B305645523FC051E003304F2 /* CreateCollectionDbRequest.swift */, B30548F52B331B1A00853966 /* CreateEditDownloadDbRequest.swift */, + B3574E3C2AC5C049009234BE /* CreateHtmlEpubAnnotationsDbRequest.swift */, B305644323FC051E003304F2 /* CreateItemFromDetailDbRequest.swift */, B305645D23FC051E003304F2 /* CreateNoteDbRequest.swift */, B32861E328BF89CE007B5A5C /* CreatePDFAnnotationsDbRequest.swift */, @@ -3118,6 +3139,7 @@ B3401D4E2567D8CF00BB8D6E /* Annotation Popover */, B3FE4B8F268DDE4900CE123F /* CitationBibliographyExport */, 61391B7B2B3C6E98003B314A /* CopyBibliography */, + B3E67C072AA751A600D7615F /* HTML:EPUB */, B3593EC3241A61C700760E20 /* ItemDetail */, B3593EE7241A61C700760E20 /* Items */, B3ADAE402833AD6700D46271 /* Lookup */, @@ -3794,6 +3816,46 @@ path = Models; sourceTree = ""; }; + B3E67C072AA751A600D7615F /* HTML:EPUB */ = { + isa = PBXGroup; + children = ( + B3E67C092AA751A600D7615F /* Models */, + B3E67C082AA751A600D7615F /* ViewModels */, + B3E67C0A2AA751A600D7615F /* Views */, + B3574E3E2AC5C53E009234BE /* HtmlEpubCoordinator.swift */, + ); + path = "HTML:EPUB"; + sourceTree = ""; + }; + B3E67C082AA751A600D7615F /* ViewModels */ = { + isa = PBXGroup; + children = ( + B3F6AA372AB303E8005BC22E /* HtmlEpubReaderActionHandler.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + B3E67C092AA751A600D7615F /* Models */ = { + isa = PBXGroup; + children = ( + B3574E3A2AC5AAC6009234BE /* HtmlEpubAnnotation.swift */, + B3F6AA3B2AB30CDA005BC22E /* HtmlEpubReaderAction.swift */, + B3F6AA352AB3039F005BC22E /* HtmlEpubReaderState.swift */, + B3F9A4B72B04C97A00684030 /* HtmlEpubSettings.swift */, + ); + path = Models; + sourceTree = ""; + }; + B3E67C0A2AA751A600D7615F /* Views */ = { + isa = PBXGroup; + children = ( + B3E67C0D2AA7631D00D7615F /* HtmlEpubDocumentViewController.swift */, + B3E67C0B2AA75DC100D7615F /* HtmlEpubReaderViewController.swift */, + B31CF9282ACED3B5009ED996 /* HtmlEpubSidebarViewController.swift */, + ); + path = Views; + sourceTree = ""; + }; B3E8FDF72714292E00F51458 /* Storage */ = { isa = PBXGroup; children = ( @@ -4683,6 +4745,8 @@ B3422F46289BADD800C53DD2 /* ItemDetailFieldEditContentView.swift in Sources */, B3FE4B99268DDE4900CE123F /* CitationBibliographyExportState.swift in Sources */, B32EBFC0276A89190003897E /* BackgroundUploadObserver.swift in Sources */, + B31CF9292ACED3B5009ED996 /* HtmlEpubSidebarViewController.swift in Sources */, + B3E67C0C2AA75DC100D7615F /* HtmlEpubReaderViewController.swift in Sources */, B3593F45241A61C700760E20 /* ItemSortTypePickerView.swift in Sources */, B30B550B24B85CC900F94B59 /* Assets.swift in Sources */, B305661523FC051E003304F2 /* UploadAttachmentSyncAction.swift in Sources */, @@ -4707,6 +4771,7 @@ B3DCDF0E240912500039ED0D /* SinglePickerActionHandler.swift in Sources */, B3593F162668E29C00FA4BB2 /* Style.swift in Sources */, B3593F37241A61C700760E20 /* ItemDetailViewController.swift in Sources */, + B3F6AA362AB3039F005BC22E /* HtmlEpubReaderState.swift in Sources */, B31941DD24531F6600BF6296 /* PDFAnnotationsViewController.swift in Sources */, B3BC6B20250FA437006A8B9C /* SchemaError.swift in Sources */, B3863FD02AD83698005082F0 /* EditTypeItemDetailDbRequest.swift in Sources */, @@ -4756,6 +4821,7 @@ B3DF9AD52747AB4F007933CB /* ApiEndpoint.swift in Sources */, B322B4772673A41200BC3D08 /* ReadStyleDbRequest.swift in Sources */, B3E8FE8827143BDD00F51458 /* SyncSettingsAction.swift in Sources */, + B3F9A4B82B04C97A00684030 /* HtmlEpubSettings.swift in Sources */, B37BF0AB25A5E2AD00AE0268 /* SquareAnnotation.swift in Sources */, B3EA5A1B2B7251EE00E283D7 /* CitationLocatorContentView.swift in Sources */, B361820024C9A7C000B30D56 /* LoginAction.swift in Sources */, @@ -4797,6 +4863,7 @@ B3DCDF1D24091EC40039ED0D /* SinglePickerView.swift in Sources */, B32EBFBD276A89190003897E /* BackgroundUploadProcessor.swift in Sources */, B3E2FF2029CAE1EF00F85AEB /* ItemsFilterCoordinator.swift in Sources */, + B3F6AA3C2AB30CDA005BC22E /* HtmlEpubReaderAction.swift in Sources */, B3593F4A241A61C700760E20 /* LibrariesState.swift in Sources */, B3E8FE072714292E00F51458 /* StorageSettingsEmptyView.swift in Sources */, B3E8FE5027142BAB00F51458 /* DebuggingActionHandler.swift in Sources */, @@ -4821,6 +4888,7 @@ B37AA28228990F6300A1C643 /* ItemDetailTitleContentView.swift in Sources */, B3486B8A26CFAFEA0036A267 /* SingleCitationState.swift in Sources */, B34A73002670C9ED00A7B186 /* SyncRepoResponseDbRequest.swift in Sources */, + B3574E3B2AC5AAC6009234BE /* HtmlEpubAnnotation.swift in Sources */, B30566A823FC051F003304F2 /* Convertible.swift in Sources */, B3868540270DC3AA0068A022 /* WebDavScheme.swift in Sources */, B3B953CA245981AD00FC96DB /* PDFReaderAction.swift in Sources */, @@ -4893,6 +4961,7 @@ B3B953CE245981DA00FC96DB /* PDFDocumentViewController.swift in Sources */, B305667F23FC051F003304F2 /* UIView+Extensions.swift in Sources */, B3DCDEE12408F5200039ED0D /* MainViewController.swift in Sources */, + B3574E3F2AC5C53E009234BE /* HtmlEpubCoordinator.swift in Sources */, B39D42D229BF84FA0035CDA9 /* TagFilterContentView.swift in Sources */, B30566A923FC051F003304F2 /* File.swift in Sources */, B305669B23FC051F003304F2 /* RSearch.swift in Sources */, @@ -5006,6 +5075,7 @@ B30565C723FC051E003304F2 /* ReadUpdatedObjectUpdateParametersDbRequest.swift in Sources */, B31CC58328646DD20055C114 /* ManualLookupActionHandler.swift in Sources */, B305661623FC051E003304F2 /* MarkGroupAsLocalOnlySyncAction.swift in Sources */, + B3E67C0E2AA7631D00D7615F /* HtmlEpubDocumentViewController.swift in Sources */, B3E8FE4C27142B5700F51458 /* DebuggingState.swift in Sources */, B372CEE02486504600B423AE /* GroupVersionsRequest.swift in Sources */, B32989D628D1D94F009B61F3 /* RObjectChange.swift in Sources */, @@ -5098,6 +5168,7 @@ B3B3FF7B266A1FAB0061E3B2 /* StoreStyleDbRequest.swift in Sources */, B3593F3A241A61C700760E20 /* ItemsAction.swift in Sources */, B30A44A629B8799600332B4E /* MasterContainerViewController.swift in Sources */, + B3F6AA382AB303E8005BC22E /* HtmlEpubReaderActionHandler.swift in Sources */, B305667823FC051F003304F2 /* Observable+Completable.swift in Sources */, B3D4159E2948B3DA004ABB3E /* FixNotesWithEmptyTitlesDbRequest.swift in Sources */, B3329A5D2B73BC8000F17636 /* CitationPreviewContentView.swift in Sources */, @@ -5305,6 +5376,7 @@ B30565FA23FC051E003304F2 /* KeyGenerator.swift in Sources */, B30566B923FC051F003304F2 /* SchemaResponse.swift in Sources */, B3593F4F241A61C700760E20 /* CollectionsActionHandler.swift in Sources */, + B3574E3D2AC5C049009234BE /* CreateHtmlEpubAnnotationsDbRequest.swift in Sources */, B357A28E285B73E800E73CA1 /* ScannerAction.swift in Sources */, B30566BD23FC051F003304F2 /* AccessPermissions.swift in Sources */, B3863FCC2AD830F0005082F0 /* EditCreatorItemDetailDbRequest.swift in Sources */, diff --git a/Zotero/Controllers/Database/Requests/CreateHtmlEpubAnnotationsDbRequest.swift b/Zotero/Controllers/Database/Requests/CreateHtmlEpubAnnotationsDbRequest.swift new file mode 100644 index 000000000..c506daeb8 --- /dev/null +++ b/Zotero/Controllers/Database/Requests/CreateHtmlEpubAnnotationsDbRequest.swift @@ -0,0 +1,126 @@ +// +// CreateHtmlEpubAnnotationsDbRequest.swift +// Zotero +// +// Created by Michal Rentka on 28.09.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +import RealmSwift + +struct CreateHtmlEpubAnnotationsDbRequest: DbRequest { + let attachmentKey: String + let libraryId: LibraryIdentifier + let annotations: [HtmlEpubAnnotation] + let userId: Int + + unowned let schemaController: SchemaController + + var needsWrite: Bool { return true } + + func process(in database: Realm) throws { + guard let parent = database.objects(RItem.self).filter(.key(self.attachmentKey, in: self.libraryId)).first else { return } + + for annotation in self.annotations { + self.create(annotation: annotation, parent: parent, in: database) + } + } + + private func create(annotation: HtmlEpubAnnotation, parent: RItem, in database: Realm) { + let item: RItem + + if let _item = database.objects(RItem.self).filter(.key(annotation.key, in: self.libraryId)).first { + if !_item.deleted { + // If item exists and is not deleted locally, we can ignore this request + return + } + + // If item exists and was already deleted locally and not yet synced, we re-add the item + item = _item + item.deleted = false + } else { + // If item didn't exist, create it + item = RItem() + item.key = annotation.key + item.rawType = ItemTypes.annotation + item.localizedType = self.schemaController.localized(itemType: ItemTypes.annotation) ?? "" + item.libraryId = self.libraryId + item.dateAdded = annotation.dateCreated + database.add(item) + } + + item.syncState = .synced + item.changeType = .user + item.htmlFreeContent = annotation.comment.isEmpty ? nil : annotation.comment.strippedRichTextTags + item.dateModified = annotation.dateModified + item.parent = parent + + if annotation.isAuthor { + item.createdBy = database.object(ofType: RUser.self, forPrimaryKey: self.userId) + } + + // We need to submit tags on creation even if they are empty, so we need to mark them as changed + self.addFields(for: annotation, to: item, database: database) + let changes: RItemChanges = [.parent, .fields, .type, .tags] + item.changes.append(RObjectChange.create(changes: changes)) + } + + private func addFields(for annotation: HtmlEpubAnnotation, to item: RItem, database: Realm) { + for field in FieldKeys.Item.Annotation.allHtmlEpubFields(for: annotation.type) { + let rField = RItemField() + rField.key = field.key + rField.baseKey = field.baseKey + rField.changed = true + + switch (field.key, field.baseKey) { + case (FieldKeys.Item.Annotation.type, nil): + rField.value = annotation.type.rawValue + + case (FieldKeys.Item.Annotation.color, nil): + rField.value = annotation.color + + case (FieldKeys.Item.Annotation.comment, nil): + rField.value = annotation.comment + + case (FieldKeys.Item.Annotation.pageLabel, nil): + rField.value = annotation.pageLabel + + case (FieldKeys.Item.Annotation.sortIndex, nil): + rField.value = annotation.sortIndex + item.annotationSortIndex = annotation.sortIndex + + case (FieldKeys.Item.Annotation.text, nil): + rField.value = annotation.text ?? "" + + case (FieldKeys.Item.Annotation.Position.htmlEpubType, FieldKeys.Item.Annotation.position): + guard let value = annotation.position[FieldKeys.Item.Annotation.Position.htmlEpubType] as? String else { continue } + rField.value = value + + case (FieldKeys.Item.Annotation.Position.htmlEpubValue, FieldKeys.Item.Annotation.position): + guard let value = annotation.position[FieldKeys.Item.Annotation.Position.htmlEpubValue] as? String else { continue } + rField.value = value + + default: break + } + + item.fields.append(rField) + } + } + + private func addTags(for annotation: HtmlEpubAnnotation, to item: RItem, database: Realm) { + let allTags = database.objects(RTag.self) + + for tag in annotation.tags { + guard let rTag = allTags.filter(.name(tag.name)).first else { continue } + + let rTypedTag = RTypedTag() + rTypedTag.type = .manual + database.add(rTypedTag) + + rTypedTag.item = item + rTypedTag.tag = rTag + } + } +} diff --git a/Zotero/Models/Defaults.swift b/Zotero/Models/Defaults.swift index 24ff7959f..0a8ee7f5e 100644 --- a/Zotero/Models/Defaults.swift +++ b/Zotero/Models/Defaults.swift @@ -107,6 +107,11 @@ final class Defaults { @CodableUserDefault(key: "PDFReaderSettings", defaultValue: PDFSettings.default, encoder: Defaults.jsonEncoder, decoder: Defaults.jsonDecoder, defaults: .standard) var pdfSettings: PDFSettings + + // MARK: - HTML / Epub Settings + + @CodableUserDefault(key: "HtmlEpubReaderSettings", defaultValue: HtmlEpubSettings.default, encoder: Defaults.jsonEncoder, decoder: Defaults.jsonDecoder, defaults: .standard) + var htmlEpubSettings: HtmlEpubSettings #endif // MARK: - Citation / Bibliography Export diff --git a/Zotero/Models/FieldKeys.swift b/Zotero/Models/FieldKeys.swift index 27f0d5556..845163dbf 100644 --- a/Zotero/Models/FieldKeys.swift +++ b/Zotero/Models/FieldKeys.swift @@ -55,6 +55,8 @@ struct FieldKeys { static let rects = "rects" static let paths = "paths" static let lineWidth = "width" + static let htmlEpubType = "type" + static let htmlEpubValue = "value" } static let type = "annotationType" @@ -122,6 +124,32 @@ struct FieldKeys { KeyBaseKeyPair(key: Annotation.Position.pageIndex, baseKey: Annotation.position)] } } + + static func allHtmlEpubFields(for type: AnnotationType) -> [KeyBaseKeyPair] { + switch type { + case .highlight: + return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.pageLabel, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), + KeyBaseKeyPair(key: Annotation.text, baseKey: nil), + KeyBaseKeyPair(key: Annotation.Position.htmlEpubType, baseKey: Annotation.position), + KeyBaseKeyPair(key: Annotation.Position.htmlEpubValue, baseKey: Annotation.position)] + + case .note: + return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.pageLabel, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), + KeyBaseKeyPair(key: Annotation.Position.htmlEpubType, baseKey: Annotation.position), + KeyBaseKeyPair(key: Annotation.Position.htmlEpubValue, baseKey: Annotation.position)] + + case .ink, .image: + return [] + } + } } static func clean(doi: String) -> String { diff --git a/Zotero/Scenes/Detail/DetailCoordinator.swift b/Zotero/Scenes/Detail/DetailCoordinator.swift index 53612be5f..2de34297c 100644 --- a/Zotero/Scenes/Detail/DetailCoordinator.swift +++ b/Zotero/Scenes/Detail/DetailCoordinator.swift @@ -194,9 +194,9 @@ final class DetailCoordinator: Coordinator { DDLogInfo("DetailCoordinator: show PDF \(attachment.key)") self.showPdf(at: url, key: attachment.key, parentKey: parentKey, library: library) - case "text/html": - DDLogInfo("DetailCoordinator: show HTML \(attachment.key)") - self.showWebView(for: url) + case "text/html", "application/epub+zip": + DDLogInfo("DetailCoordinator: show HTML / EPUB \(attachment.key)") + self.showHtmlEpubReader(for: url, key: attachment.key, library: library) case "text/plain": let text = try? String(contentsOf: url, encoding: .utf8) @@ -302,6 +302,18 @@ final class DetailCoordinator: Coordinator { navigationController?.present(controller, animated: true, completion: nil) } + private func showHtmlEpubReader(for url: URL, key: String, library: Library) { + let navigationController = NavigationViewController() + navigationController.modalPresentationStyle = .fullScreen + + let coordinator = HtmlEpubCoordinator(key: key, library: library, url: url, navigationController: navigationController, controllers: controllers) + coordinator.parentCoordinator = self + self.childCoordinators.append(coordinator) + coordinator.start(animated: false) + + self.navigationController?.present(navigationController, animated: true, completion: nil) + } + private func showWebView(for url: URL) { guard let currentNavigationController = self.navigationController else { return } let controller = WebViewController(url: url) diff --git a/Zotero/Scenes/Detail/HTML:EPUB/HtmlEpubCoordinator.swift b/Zotero/Scenes/Detail/HTML:EPUB/HtmlEpubCoordinator.swift new file mode 100644 index 000000000..1196b117f --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/HtmlEpubCoordinator.swift @@ -0,0 +1,321 @@ +// +// HtmlEpubCoordinator.swift +// Zotero +// +// Created by Michal Rentka on 28.09.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import CocoaLumberjackSwift +import RxSwift + +protocol HtmlEpubReaderCoordinatorDelegate: AnyObject { + func show(error: HtmlEpubReaderState.Error) + func showToolSettings(tool: AnnotationTool, colorHex: String?, sizeValue: Float?, sender: SourceView, userInterfaceStyle: UIUserInterfaceStyle, valueChanged: @escaping (String?, Float?) -> Void) +} + +protocol HtmlEpubSidebarCoordinatorDelegate: AnyObject { + func showTagPicker(libraryId: LibraryIdentifier, selected: Set, userInterfaceStyle: UIUserInterfaceStyle?, picked: @escaping ([Tag]) -> Void) + func showCellOptions( + for annotation: HtmlEpubAnnotation, + userId: Int, + library: Library, + sender: UIButton, + userInterfaceStyle: UIUserInterfaceStyle, + saveAction: @escaping AnnotationEditSaveAction, + deleteAction: @escaping AnnotationEditDeleteAction + ) + func showAnnotationPopover( + viewModel: ViewModel, + sourceRect: CGRect, + popoverDelegate: UIPopoverPresentationControllerDelegate, + userInterfaceStyle: UIUserInterfaceStyle + ) -> PublishSubject? + func showFilterPopup( + from barButton: UIBarButtonItem, + filter: AnnotationsFilter?, + availableColors: [String], + availableTags: [Tag], + userInterfaceStyle: UIUserInterfaceStyle, + completed: @escaping (AnnotationsFilter?) -> Void + ) + func showSettings(with settings: HtmlEpubSettings, sender: UIBarButtonItem) -> ViewModel +} + +final class HtmlEpubCoordinator: Coordinator { + weak var parentCoordinator: Coordinator? + var childCoordinators: [Coordinator] + weak var navigationController: UINavigationController? + + private let key: String + private let library: Library + private let url: URL + private unowned let controllers: Controllers + private let disposeBag: DisposeBag + + init(key: String, library: Library, url: URL, navigationController: NavigationViewController, controllers: Controllers) { + self.key = key + self.library = library + self.url = url + self.navigationController = navigationController + self.controllers = controllers + self.childCoordinators = [] + self.disposeBag = DisposeBag() + + navigationController.dismissHandler = { + self.parentCoordinator?.childDidFinish(self) + } + } + + deinit { + DDLogInfo("HtmlEpubCoordinator: deinitialized") + } + + func start(animated: Bool) { + let username = Defaults.shared.username + guard let dbStorage = self.controllers.userControllers?.dbStorage, + let userId = self.controllers.sessionController.sessionData?.userId, + !username.isEmpty, + let parentNavigationController = self.parentCoordinator?.navigationController + else { return } + + let handler = HtmlEpubReaderActionHandler( + dbStorage: dbStorage, + schemaController: controllers.schemaController, + htmlAttributedStringConverter: controllers.htmlAttributedStringConverter, + dateParser: controllers.dateParser, + idleTimerController: controllers.idleTimerController + ) + let state = HtmlEpubReaderState(url: url, key: key, settings: Defaults.shared.htmlEpubSettings, library: library, userId: userId, username: username) + let controller = HtmlEpubReaderViewController( + viewModel: ViewModel(initialState: state, handler: handler), + compactSize: UIDevice.current.isCompactWidth(size: parentNavigationController.view.frame.size) + ) + controller.coordinatorDelegate = self + + self.navigationController?.setViewControllers([controller], animated: false) + } +} + +extension HtmlEpubCoordinator: HtmlEpubReaderCoordinatorDelegate { + func show(error: HtmlEpubReaderState.Error) { + let title: String + let message: String + + switch error { + case .cantAddAnnotations: + title = L10n.error + message = L10n.Errors.Pdf.cantAddAnnotations + + case .cantDeleteAnnotation: + title = L10n.error + message = L10n.Errors.Pdf.cantDeleteAnnotations + + case .cantUpdateAnnotation: + title = L10n.error + message = L10n.Errors.Pdf.cantUpdateAnnotation + + case .incompatibleDocument: + title = L10n.error + message = L10n.Errors.Pdf.cantUpdateAnnotation + + case .unknown: + title = L10n.error + message = L10n.Errors.unknown + } + + let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) + controller.addAction(UIAlertAction(title: L10n.ok, style: .default)) + self.navigationController?.present(controller, animated: true) + } + + func showToolSettings(tool: AnnotationTool, colorHex: String?, sizeValue: Float?, sender: SourceView, userInterfaceStyle: UIUserInterfaceStyle, valueChanged: @escaping (String?, Float?) -> Void) { + DDLogInfo("HtmlEpubCoordinator: show tool settings for \(tool)") + let state = AnnotationToolOptionsState(tool: tool, colorHex: colorHex, size: sizeValue) + let handler = AnnotationToolOptionsActionHandler() + let controller = AnnotationToolOptionsViewController(viewModel: ViewModel(initialState: state, handler: handler), valueChanged: valueChanged) + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + controller.overrideUserInterfaceStyle = userInterfaceStyle + controller.modalPresentationStyle = .popover + switch sender { + case .view(let view, _): + controller.popoverPresentationController?.sourceView = view + + case .item(let item): + controller.popoverPresentationController?.barButtonItem = item + } + self.navigationController?.present(controller, animated: true, completion: nil) + + default: + let navigationController = UINavigationController(rootViewController: controller) + navigationController.modalPresentationStyle = .formSheet + navigationController.overrideUserInterfaceStyle = userInterfaceStyle + self.navigationController?.present(navigationController, animated: true, completion: nil) + } + } +} + +extension HtmlEpubCoordinator: HtmlEpubSidebarCoordinatorDelegate { + func showTagPicker(libraryId: LibraryIdentifier, selected: Set, userInterfaceStyle: UIUserInterfaceStyle?, picked: @escaping ([Tag]) -> Void) { + guard let navigationController else { return } + (self.parentCoordinator as? DetailCoordinator)?.showTagPicker( + libraryId: libraryId, + selected: selected, + userInterfaceStyle: userInterfaceStyle, + navigationController: navigationController, + picked: picked + ) + } + + func showCellOptions( + for annotation: HtmlEpubAnnotation, + userId: Int, + library: Library, + sender: UIButton, + userInterfaceStyle: UIUserInterfaceStyle, + saveAction: @escaping AnnotationEditSaveAction, + deleteAction: @escaping AnnotationEditDeleteAction + ) { + let navigationController = NavigationViewController() + navigationController.overrideUserInterfaceStyle = userInterfaceStyle + + let coordinator = AnnotationEditCoordinator( + data: AnnotationEditState.AnnotationData( + type: annotation.type, + isEditable: annotation.editability(currentUserId: userId, library: library) == .editable, + color: annotation.color, + lineWidth: 0, + pageLabel: annotation.pageLabel, + highlightText: annotation.text ?? "" + ), + saveAction: saveAction, + deleteAction: deleteAction, + navigationController: navigationController, + controllers: self.controllers + ) + coordinator.parentCoordinator = self + self.childCoordinators.append(coordinator) + coordinator.start(animated: false) + + if UIDevice.current.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .popover + navigationController.popoverPresentationController?.sourceView = sender + navigationController.popoverPresentationController?.permittedArrowDirections = .left + } + + self.navigationController?.present(navigationController, animated: true, completion: nil) + } + + func showAnnotationPopover( + viewModel: ViewModel, + sourceRect: CGRect, + popoverDelegate: UIPopoverPresentationControllerDelegate, + userInterfaceStyle: UIUserInterfaceStyle + ) -> PublishSubject? { + guard let currentNavigationController = self.navigationController, let annotation = viewModel.state.selectedAnnotationKey.flatMap({ viewModel.state.annotations[$0] }) else { return nil } + + DDLogInfo("HtmlEpubCoordinator: show annotation popover") + + if let coordinator = self.childCoordinators.last, coordinator is AnnotationPopoverCoordinator { + return nil + } + + let navigationController = NavigationViewController() + navigationController.overrideUserInterfaceStyle = userInterfaceStyle + + let author = viewModel.state.library.identifier == .custom(.myLibrary) ? "" : annotation.author + let comment = viewModel.state.comments[annotation.key] ?? NSAttributedString() + let editability = annotation.editability(currentUserId: viewModel.state.userId, library: viewModel.state.library) + + let data = AnnotationPopoverState.Data( + libraryId: viewModel.state.library.identifier, + type: annotation.type, + isEditable: editability == .editable, + author: author, + comment: comment, + color: annotation.color, + lineWidth: 0, + pageLabel: annotation.pageLabel, + highlightText: annotation.text ?? "", + tags: annotation.tags, + showsDeleteButton: editability != .notEditable + ) + let coordinator = AnnotationPopoverCoordinator(data: data, navigationController: navigationController, controllers: self.controllers) + coordinator.parentCoordinator = self + self.childCoordinators.append(coordinator) + coordinator.start(animated: false) + + if UIDevice.current.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .popover + navigationController.popoverPresentationController?.sourceView = currentNavigationController.view + navigationController.popoverPresentationController?.sourceRect = sourceRect + navigationController.popoverPresentationController?.permittedArrowDirections = [.left, .right] + navigationController.popoverPresentationController?.delegate = popoverDelegate + } + + currentNavigationController.present(navigationController, animated: true, completion: nil) + + return coordinator.viewModelObservable + } + + func showFilterPopup( + from barButton: UIBarButtonItem, + filter: AnnotationsFilter?, + availableColors: [String], + availableTags: [Tag], + userInterfaceStyle: UIUserInterfaceStyle, + completed: @escaping (AnnotationsFilter?) -> Void + ) { + DDLogInfo("HtmlEpubCoordinator: show annotations filter popup") + + let navigationController = NavigationViewController() + navigationController.overrideUserInterfaceStyle = userInterfaceStyle + + let coordinator = AnnotationsFilterPopoverCoordinator( + initialFilter: filter, + availableColors: availableColors, + availableTags: availableTags, + navigationController: navigationController, + controllers: self.controllers, + completionHandler: completed + ) + coordinator.parentCoordinator = self + self.childCoordinators.append(coordinator) + coordinator.start(animated: false) + + if UIDevice.current.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .popover + navigationController.popoverPresentationController?.barButtonItem = barButton + navigationController.popoverPresentationController?.permittedArrowDirections = .down + } + + self.navigationController?.present(navigationController, animated: true, completion: nil) + } + + func showSettings(with settings: HtmlEpubSettings, sender: UIBarButtonItem) -> ViewModel { + DDLogInfo("HtmlEpubCoordinator: show settings") + + let state = ReaderSettingsState(settings: settings) + let viewModel = ViewModel(initialState: state, handler: ReaderSettingsActionHandler()) + let baseController = ReaderSettingsViewController(visibleRows: [.appearance, .sleep], viewModel: viewModel) + + let controller: UIViewController + if UIDevice.current.userInterfaceIdiom == .pad { + controller = baseController + } else { + controller = UINavigationController(rootViewController: baseController) + } + + controller.modalPresentationStyle = UIDevice.current.userInterfaceIdiom == .pad ? .popover : .formSheet + controller.popoverPresentationController?.barButtonItem = sender + controller.preferredContentSize = CGSize(width: 480, height: 92) + controller.overrideUserInterfaceStyle = settings.appearance.userInterfaceStyle + self.navigationController?.present(controller, animated: true, completion: nil) + + return viewModel + } +} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubAnnotation.swift b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubAnnotation.swift new file mode 100644 index 000000000..e3f9defcc --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubAnnotation.swift @@ -0,0 +1,56 @@ +// +// HtmlEpubAnnotation.swift +// Zotero +// +// Created by Michal Rentka on 28.09.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +struct HtmlEpubAnnotation { + let key: String + let type: AnnotationType + let pageLabel: String + let position: [String: Any] + let author: String + let isAuthor: Bool + let color: String + let comment: String + let text: String? + let sortIndex: String + let dateModified: Date + let dateCreated: Date + let tags: [Tag] + + func copy(comment: String) -> HtmlEpubAnnotation { + return HtmlEpubAnnotation( + key: key, + type: type, + pageLabel: pageLabel, + position: position, + author: author, + isAuthor: isAuthor, + color: color, + comment: comment, + text: text, + sortIndex: sortIndex, + dateModified: dateModified, + dateCreated: dateCreated, + tags: tags + ) + } + + func editability(currentUserId: Int, library: Library) -> AnnotationEditability { + switch library.identifier { + case .custom: + return library.metadataEditable ? .editable : .notEditable + + case .group: + if !library.metadataEditable { + return .notEditable + } + return self.isAuthor ? .editable : .deletable + } + } +} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderAction.swift b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderAction.swift new file mode 100644 index 000000000..6ba367d0d --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderAction.swift @@ -0,0 +1,36 @@ +// +// HtmlEpubReaderAction.swift +// Zotero +// +// Created by Michal Rentka on 14.09.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +enum HtmlEpubReaderAction { + case changeFilter(AnnotationsFilter?) + case changeIdleTimerDisabled(Bool) + case deselectAnnotationDuringEditing(String) + case deselectSelectedAnnotation + case loadDocument + case parseAndCacheComment(key: String, comment: String) + case removeAnnotation(String) + case removeSelectedAnnotations + case saveAnnotations([String: Any]) + case searchAnnotations(String) + case searchDocument(String) + case selectAnnotationDuringEditing(String) + case selectAnnotationFromSidebar(String) + case selectAnnotationFromDocument(key: String, rect: CGRect) + case setColor(key: String, color: String) + case setComment(key: String, comment: NSAttributedString) + case setCommentActive(Bool) + case setSettings(HtmlEpubSettings) + case setSidebarEditingEnabled(Bool) + case setTags(key: String, tags: [Tag]) + case setToolOptions(color: String?, size: CGFloat?, tool: AnnotationTool) + case setViewState([String: Any]) + case toggleTool(AnnotationTool) + case updateAnnotationProperties(key: String, color: String, lineWidth: CGFloat, pageLabel: String, updateSubsequentLabels: Bool, highlightText: String) +} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderState.swift b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderState.swift new file mode 100644 index 000000000..b619ae4c8 --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubReaderState.swift @@ -0,0 +1,124 @@ +// +// HtmlEpubReaderState.swift +// Zotero +// +// Created by Michal Rentka on 14.09.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import RealmSwift + +struct HtmlEpubReaderState: ViewModelState { + struct Changes: OptionSet { + typealias RawValue = UInt16 + + let rawValue: UInt16 + + static let activeTool = Changes(rawValue: 1 << 0) + static let annotations = Changes(rawValue: 1 << 1) + static let selection = Changes(rawValue: 1 << 2) + static let activeComment = Changes(rawValue: 1 << 3) + static let sidebarEditing = Changes(rawValue: 1 << 4) + static let filter = Changes(rawValue: 1 << 5) + static let toolColor = Changes(rawValue: 1 << 6) + static let sidebarEditingSelection = Changes(rawValue: 1 << 7) + static let settings = Changes(rawValue: 1 << 8) + } + + struct DocumentData { + enum Page { + case html(scrollYPercent: Double) + case epub(cfi: String) + } + + let type: String + let buffer: String + let annotationsJson: String + let page: Page? + } + + struct DocumentUpdate { + let deletions: [String] + let insertions: [[String: Any]] + let modifications: [[String: Any]] + } + + enum Error: Swift.Error { + case cantDeleteAnnotation + case cantAddAnnotations + case cantUpdateAnnotation + case incompatibleDocument + case unknown + } + + let url: URL + let key: String + let library: Library + let userId: Int + let username: String + let commentFont: UIFont + + var documentData: DocumentData? + var settings: HtmlEpubSettings + var activeTool: AnnotationTool? + var toolColors: [AnnotationTool: UIColor] + var sortedKeys: [String] + var snapshotKeys: [String]? + var annotations: [String: HtmlEpubAnnotation] + var annotationSearchTerm: String? + var annotationFilter: AnnotationsFilter? + var selectedAnnotationKey: String? + var selectedAnnotationRect: CGRect? + var documentSearchTerm: String? + var comments: [String: NSAttributedString] + var changes: Changes + var error: Error? + /// Updates that need to be performed on html/epub document + var documentUpdate: DocumentUpdate? + /// Annotation keys in sidebar that need to reload (for example cell height) + var updatedAnnotationKeys: [String]? + /// Annotation key to focus in annotation sidebar + var focusSidebarKey: String? + /// Annotation key to focus in document + var focusDocumentKey: String? + var selectedAnnotationCommentActive: Bool + var sidebarEditingEnabled: Bool + var notificationToken: NotificationToken? + var deletionEnabled: Bool + /// Selected annotations when annotations are being edited in sidebar + var selectedAnnotationsDuringEditing: Set + + init(url: URL, key: String, settings: HtmlEpubSettings, library: Library, userId: Int, username: String) { + self.url = url + self.key = key + self.settings = settings + self.library = library + self.userId = userId + self.username = username + self.commentFont = PDFReaderLayout.annotationLayout.font + self.sortedKeys = [] + self.annotations = [:] + self.comments = [:] + self.sidebarEditingEnabled = false + self.selectedAnnotationCommentActive = false + self.toolColors = [ + .highlight: UIColor(hex: Defaults.shared.highlightColorHex), + .note: UIColor(hex: Defaults.shared.noteColorHex) + ] + self.changes = [] + self.deletionEnabled = false + self.selectedAnnotationsDuringEditing = [] + } + + mutating func cleanup() { + documentData = nil + documentUpdate = nil + changes = [] + error = nil + focusSidebarKey = nil + focusDocumentKey = nil + updatedAnnotationKeys = nil + } +} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubSettings.swift b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubSettings.swift new file mode 100644 index 000000000..f5fa1594a --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/Models/HtmlEpubSettings.swift @@ -0,0 +1,37 @@ +// +// HtmlEpubSettings.swift +// Zotero +// +// Created by Michal Rentka on 15.11.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +struct HtmlEpubSettings { + var appearance: ReaderSettingsState.Appearance + var idleTimerDisabled: Bool + + static var `default`: HtmlEpubSettings { + return HtmlEpubSettings(appearance: .automatic, idleTimerDisabled: false) + } +} + +extension HtmlEpubSettings: Codable { + enum Keys: String, CodingKey { + case appearance + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Keys.self) + let appearanceRaw = try container.decode(UInt.self, forKey: .appearance) + self.appearance = ReaderSettingsState.Appearance(rawValue: appearanceRaw) ?? .automatic + // This setting is not persisted, always defaults to false + self.idleTimerDisabled = false + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + try container.encode(self.appearance.rawValue, forKey: .appearance) + } +} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/ViewModels/HtmlEpubReaderActionHandler.swift b/Zotero/Scenes/Detail/HTML:EPUB/ViewModels/HtmlEpubReaderActionHandler.swift new file mode 100644 index 000000000..91d417bbe --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/ViewModels/HtmlEpubReaderActionHandler.swift @@ -0,0 +1,877 @@ +// +// HtmlEpubReaderActionHandler.swift +// Zotero +// +// Created by Michal Rentka on 14.09.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import CocoaLumberjackSwift +import RealmSwift + +final class HtmlEpubReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionHandler { + typealias Action = HtmlEpubReaderAction + typealias State = HtmlEpubReaderState + + unowned let dbStorage: DbStorage + private unowned let schemaController: SchemaController + private unowned let htmlAttributedStringConverter: HtmlAttributedStringConverter + private unowned let dateParser: DateParser + private unowned let idleTimerController: IdleTimerController + let backgroundQueue: DispatchQueue + + init(dbStorage: DbStorage, schemaController: SchemaController, htmlAttributedStringConverter: HtmlAttributedStringConverter, dateParser: DateParser, idleTimerController: IdleTimerController) { + self.dbStorage = dbStorage + self.schemaController = schemaController + self.htmlAttributedStringConverter = htmlAttributedStringConverter + self.dateParser = dateParser + self.idleTimerController = idleTimerController + self.backgroundQueue = DispatchQueue(label: "org.zotero.Zotero.HtmlEpubReaderActionHandler.queue", qos: .userInteractive) + } + + func process(action: HtmlEpubReaderAction, in viewModel: ViewModel) { + switch action { + case .toggleTool(let tool): + toggle(tool: tool, in: viewModel) + + case .loadDocument: + load(in: viewModel) + + case .removeAnnotation(let key): + remove(keys: [key], in: viewModel) + + case .saveAnnotations(let params): + saveAnnotations(params: params, in: viewModel) + + case .searchAnnotations(let term): + searchAnnotations(for: term, in: viewModel) + + case .searchDocument(let term): + update(viewModel: viewModel) { state in + state.documentSearchTerm = term + } + + case .selectAnnotationFromSidebar(let key): + self.update(viewModel: viewModel) { state in + _select(data: (key, nil), didSelectInDocument: false, state: &state) + } + + case .selectAnnotationFromDocument(let key, let rect): + update(viewModel: viewModel) { state in + _select(data: (key, rect), didSelectInDocument: true, state: &state) + } + + case .setComment(let key, let comment): + set(comment: comment, key: key, viewModel: viewModel) + + case .setCommentActive(let isActive): + update(viewModel: viewModel) { state in + state.selectedAnnotationCommentActive = isActive + } + + case .setTags(let key, let tags): + set(tags: tags, to: key, in: viewModel) + + case .deselectSelectedAnnotation: + self.update(viewModel: viewModel) { state in + _select(data: nil, didSelectInDocument: false, state: &state) + } + + case .parseAndCacheComment(key: let key, comment: let comment): + update(viewModel: viewModel, notifyListeners: false) { state in + state.comments[key] = self.htmlAttributedStringConverter.convert(text: comment, baseAttributes: [.font: viewModel.state.commentFont]) + } + + case .updateAnnotationProperties(let key, let color, let lineWidth, let pageLabel, let updateSubsequentLabels, let highlightText): + set(color: color, lineWidth: lineWidth, pageLabel: pageLabel, updateSubsequentLabels: updateSubsequentLabels, highlightText: highlightText, key: key, viewModel: viewModel) + + case .setColor(key: let key, color: let color): + set(color: color, key: key, viewModel: viewModel) + + case .setViewState(let params): + setViewState(params: params, in: viewModel) + + case .setToolOptions(let color, let size, let tool): + setTool(color: color, size: size, tool: tool, in: viewModel) + + case .setSidebarEditingEnabled(let isEnabled): + setSidebar(editing: isEnabled, in: viewModel) + + case .removeSelectedAnnotations: + removeSelectedAnnotations(in: viewModel) + + case .selectAnnotationDuringEditing(let key): + selectDuringEditing(key: key, in: viewModel) + + case .deselectAnnotationDuringEditing(let key): + deselectDuringEditing(key: key, in: viewModel) + + case .changeFilter(let filter): + set(filter: filter, in: viewModel) + + case .setSettings(let settings): + set(settings: settings, in: viewModel) + + case .changeIdleTimerDisabled(let disabled): + changeIdleTimer(disabled: disabled, in: viewModel) + } + } + + private func changeIdleTimer(disabled: Bool, in viewModel: ViewModel) { + guard viewModel.state.settings.idleTimerDisabled != disabled else { return } + var settings = viewModel.state.settings + settings.idleTimerDisabled = disabled + + update(viewModel: viewModel) { state in + state.settings = settings + // Don't need to assign `changes` or update Defaults.shared.htmlEpubSettings, this setting is not stored and doesn't change anything else + } + + if settings.idleTimerDisabled { + self.idleTimerController.disable() + } else { + self.idleTimerController.enable() + } + } + + private func set(settings: HtmlEpubSettings, in viewModel: ViewModel) { + if viewModel.state.settings.idleTimerDisabled != settings.idleTimerDisabled { + if settings.idleTimerDisabled { + self.idleTimerController.disable() + } else { + self.idleTimerController.enable() + } + } + + update(viewModel: viewModel) { state in + state.settings = settings + state.changes = .settings + } + + Defaults.shared.htmlEpubSettings = settings + } + + private func removeSelectedAnnotations(in viewModel: ViewModel) { + guard !viewModel.state.selectedAnnotationsDuringEditing.isEmpty else { return } + let keys = viewModel.state.selectedAnnotationsDuringEditing + + update(viewModel: viewModel) { state in + state.deletionEnabled = false + state.selectedAnnotationsDuringEditing = [] + state.changes = .sidebarEditingSelection + } + + remove(keys: Array(keys), in: viewModel) + } + + private func setSidebar(editing enabled: Bool, in viewModel: ViewModel) { + update(viewModel: viewModel) { state in + state.sidebarEditingEnabled = enabled + state.changes = .sidebarEditing + + if enabled { + // Deselect selected annotation before editing + _select(data: nil, didSelectInDocument: false, state: &state) + } else { + // Deselect selected annotations during editing + state.selectedAnnotationsDuringEditing = [] + state.deletionEnabled = false + } + } + } + + private func selectDuringEditing(key: String, in viewModel: ViewModel) { + guard let annotation = viewModel.state.annotations[key] else { return } + + let annotationDeletable = annotation.editability(currentUserId: viewModel.state.userId, library: viewModel.state.library) != .notEditable + + self.update(viewModel: viewModel) { state in + if state.selectedAnnotationsDuringEditing.isEmpty { + state.deletionEnabled = annotationDeletable + } else { + state.deletionEnabled = state.deletionEnabled && annotationDeletable + } + + state.selectedAnnotationsDuringEditing.insert(key) + state.changes = .sidebarEditingSelection + } + } + + private func deselectDuringEditing(key: String, in viewModel: ViewModel) { + self.update(viewModel: viewModel) { state in + state.selectedAnnotationsDuringEditing.remove(key) + + if state.selectedAnnotationsDuringEditing.isEmpty { + if state.deletionEnabled { + state.deletionEnabled = false + state.changes = .sidebarEditingSelection + } + } else { + // Check whether deletion state changed after removing this annotation + let deletionEnabled = selectedAnnotationsDeletable(selected: state.selectedAnnotationsDuringEditing, in: viewModel) + if state.deletionEnabled != deletionEnabled { + state.deletionEnabled = deletionEnabled + state.changes = .sidebarEditingSelection + } + } + } + + func selectedAnnotationsDeletable(selected: Set, in viewModel: ViewModel) -> Bool { + return !selected.contains(where: { key in + guard let annotation = viewModel.state.annotations[key] else { return false } + return annotation.editability(currentUserId: viewModel.state.userId, library: viewModel.state.library) == .notEditable + }) + } + } + + private func setTool(color: String?, size: CGFloat?, tool: AnnotationTool, in viewModel: ViewModel) { + update(viewModel: viewModel) { state in + state.toolColors[tool] = color.flatMap({ UIColor(hex: $0) }) + state.changes = .toolColor + } + } + + private func setViewState(params: [String: Any], in viewModel: ViewModel) { + guard let state = params["state"] as? [String: Any] else { + DDLogError("HtmlEpubReaderActionHandler: invalid params - \(params)") + return + } + + let page: String + if let scrollPercent = state["scrollYPercent"] as? Double { + page = "\(Decimal(scrollPercent).rounded(to: 1))" + } else if let cfi = state["cfi"] as? String { + page = cfi + } else { + return + } + + let request = StorePageForItemDbRequest(key: viewModel.state.key, libraryId: viewModel.state.library.identifier, page: page) + self.perform(request: request) { error in + guard let error = error else { return } + // TODO: - handle error + DDLogError("HtmlEpubReaderActionHandler: can't store page - \(error)") + } + } + + private func remove(keys: [String], in viewModel: ViewModel) { + DDLogInfo("HtmlEpubReaderActionHandler: annotations deleted - keys=\(keys)") + + guard !keys.isEmpty else { return } + + let request = MarkObjectsAsDeletedDbRequest(keys: keys, libraryId: viewModel.state.library.identifier) + self.perform(request: request) { [weak self, weak viewModel] error in + guard let self = self, let viewModel = viewModel else { return } + + if let error = error { + DDLogError("HtmlEpubReaderActionHandler: can't remove annotations \(keys) - \(error)") + + self.update(viewModel: viewModel) { state in + state.error = .cantDeleteAnnotation + } + } + } + } + + private func set(color: String, lineWidth: CGFloat, pageLabel: String, updateSubsequentLabels: Bool, highlightText: String, key: String, viewModel: ViewModel) { + let values = [ + KeyBaseKeyPair(key: FieldKeys.Item.Annotation.pageLabel, baseKey: nil): pageLabel, + KeyBaseKeyPair(key: FieldKeys.Item.Annotation.text, baseKey: nil): highlightText, + KeyBaseKeyPair(key: FieldKeys.Item.Annotation.color, baseKey: nil): color, + KeyBaseKeyPair(key: FieldKeys.Item.Annotation.Position.lineWidth, baseKey: FieldKeys.Item.Annotation.position): "\(Decimal(lineWidth).rounded(to: 3))" + ] + let request = EditItemFieldsDbRequest(key: key, libraryId: viewModel.state.library.identifier, fieldValues: values, dateParser: dateParser) + self.perform(request: request) { [weak self, weak viewModel] error in + guard let error = error, let self = self, let viewModel = viewModel else { return } + + DDLogError("HtmlEpubReaderActionHandler: can't update annotation \(key) - \(error)") + + self.update(viewModel: viewModel) { state in + state.error = .cantUpdateAnnotation + } + } + } + + private func set(color: String, key: String, viewModel: ViewModel) { + let values = [KeyBaseKeyPair(key: FieldKeys.Item.Annotation.color, baseKey: nil): color] + let request = EditItemFieldsDbRequest(key: key, libraryId: viewModel.state.library.identifier, fieldValues: values, dateParser: dateParser) + self.perform(request: request) { [weak self, weak viewModel] error in + guard let error = error, let self = self, let viewModel = viewModel else { return } + + DDLogError("HtmlEpubReaderActionHandler: can't set color \(key) - \(error)") + + self.update(viewModel: viewModel) { state in + state.error = .cantUpdateAnnotation + } + } + } + + private func set(comment: NSAttributedString, key: String, viewModel: ViewModel) { + let htmlComment = htmlAttributedStringConverter.convert(attributedString: comment) + + update(viewModel: viewModel) { state in + state.comments[key] = comment + } + + let values = [KeyBaseKeyPair(key: FieldKeys.Item.Annotation.comment, baseKey: nil): htmlComment] + let request = EditItemFieldsDbRequest(key: key, libraryId: viewModel.state.library.identifier, fieldValues: values, dateParser: dateParser) + perform(request: request) { error in + guard let error else { return } + + DDLogError("HtmlEpubReaderActionHandler: can't set comment \(key) - \(error)") + + self.update(viewModel: viewModel) { state in + state.error = .cantUpdateAnnotation + } + } + } + + private func toggle(tool: AnnotationTool, in viewModel: ViewModel) { + update(viewModel: viewModel) { state in + if state.activeTool == tool { + state.activeTool = nil + } else { + state.activeTool = tool + } + state.changes = .activeTool + } + } + + private func set(tags: [Tag], to key: String, in viewModel: ViewModel) { + let request = EditTagsForItemDbRequest(key: key, libraryId: viewModel.state.library.identifier, tags: tags) + perform(request: request) { [weak self, weak viewModel] error in + guard let error, let self, let viewModel else { return } + + DDLogError("HtmlEpubReaderActionHandler: can't set tags \(key) - \(error)") + + self.update(viewModel: viewModel) { state in + state.error = .cantUpdateAnnotation + } + } + } + + private func _select(data: (String, CGRect?)?, didSelectInDocument: Bool, state: inout HtmlEpubReaderState) { + guard data?.0 != state.selectedAnnotationKey else { return } + + if let existing = state.selectedAnnotationKey { + add(updatedAnnotationKey: existing, state: &state) + + if state.selectedAnnotationCommentActive { + state.selectedAnnotationCommentActive = false + state.changes.insert(.activeComment) + } + } + + state.changes.insert(.selection) + + guard let (key, rect) = data else { + state.selectedAnnotationKey = nil + state.selectedAnnotationRect = nil + return + } + + state.selectedAnnotationKey = key + state.selectedAnnotationRect = rect + + if !didSelectInDocument { + state.focusDocumentKey = key + } else { + state.focusSidebarKey = key + } + + add(updatedAnnotationKey: key, state: &state) + + func add(updatedAnnotationKey key: String, state: inout HtmlEpubReaderState) { + if state.annotations.contains(where: { $0.key == key }) { + var updatedAnnotationKeys = state.updatedAnnotationKeys ?? [] + updatedAnnotationKeys.append(key) + state.updatedAnnotationKeys = updatedAnnotationKeys + } + } + } + + private func set(filter: AnnotationsFilter?, in viewModel: ViewModel) { + guard filter != viewModel.state.annotationFilter else { return } + self.filterAnnotations(with: viewModel.state.annotationSearchTerm, filter: filter, in: viewModel) + } + + private func searchAnnotations(for term: String, in viewModel: ViewModel) { + let trimmedTerm = term.trimmingCharacters(in: .whitespacesAndNewlines) + let newTerm = trimmedTerm.isEmpty ? nil : trimmedTerm + guard newTerm != viewModel.state.annotationSearchTerm else { return } + self.filterAnnotations(with: newTerm, filter: viewModel.state.annotationFilter, in: viewModel) + } + + /// Filters annotations based on given term and filer parameters. + /// - parameter term: Term to filter annotations. + /// - parameter viewModel: ViewModel. + private func filterAnnotations(with term: String?, filter: AnnotationsFilter?, in viewModel: ViewModel) { + if term == nil && filter == nil { + guard let snapshot = viewModel.state.snapshotKeys else { return } + + // TODO: - Unhide document annotations + + self.update(viewModel: viewModel) { state in + state.snapshotKeys = nil + state.sortedKeys = snapshot + state.changes = .annotations + + if state.annotationFilter != nil { + state.changes.insert(.filter) + } + + state.annotationSearchTerm = nil + state.annotationFilter = nil + } + return + } + + let snapshot = viewModel.state.snapshotKeys ?? viewModel.state.sortedKeys + let filteredKeys = self.filteredKeys(from: snapshot, term: term, filter: filter, state: viewModel.state) + + // TODO: - Hide document annotations + + self.update(viewModel: viewModel) { state in + if state.snapshotKeys == nil { + state.snapshotKeys = state.sortedKeys + } + state.sortedKeys = filteredKeys + state.changes = .annotations + + if filter != state.annotationFilter { + state.changes.insert(.filter) + } + + state.annotationSearchTerm = term + state.annotationFilter = filter + } + } + + private func filteredKeys(from snapshot: [String], term: String?, filter: AnnotationsFilter?, state: HtmlEpubReaderState) -> [String] { + if term == nil && filter == nil { + return snapshot + } + return snapshot.filter({ key in + guard let annotation = state.annotations[key] else { return false } + return self.filter(annotation: annotation, with: term) && self.filter(annotation: annotation, with: filter) + }) + } + + private func filter(annotation: HtmlEpubAnnotation, with term: String?) -> Bool { + guard let term = term else { return true } + return annotation.key.lowercased() == term.lowercased() || + annotation.author.localizedCaseInsensitiveContains(term) || + annotation.comment.localizedCaseInsensitiveContains(term) || + (annotation.text ?? "").localizedCaseInsensitiveContains(term) || + annotation.tags.contains(where: { $0.name.localizedCaseInsensitiveContains(term) }) + } + + private func filter(annotation: HtmlEpubAnnotation, with filter: AnnotationsFilter?) -> Bool { + guard let filter = filter else { return true } + let hasTag = filter.tags.isEmpty ? true : annotation.tags.contains(where: { filter.tags.contains($0.name) }) + let hasColor = filter.colors.isEmpty ? true : filter.colors.contains(annotation.color) + return hasTag && hasColor + } + + private func saveAnnotations(params: [String: Any], in viewModel: ViewModel) { + guard let rawAnnotations = params["annotations"] as? [[String: Any]], !rawAnnotations.isEmpty else { + DDLogError("HtmlEpubReaderActionHandler: annotations missing or empty - \(params["annotations"] ?? [])") + return + } + + let annotations = parse(annotations: rawAnnotations, author: viewModel.state.username, isAuthor: true) + + guard !annotations.isEmpty else { + DDLogError("HtmlEpubReaderActionHandler: could not parse annotations") + return + } + + // Disable annotation tool + if annotations.contains(where: { $0.type == .note }) { + update(viewModel: viewModel) { state in + state.activeTool = nil + state.changes = .activeTool + } + } + + let request = CreateHtmlEpubAnnotationsDbRequest( + attachmentKey: viewModel.state.key, + libraryId: viewModel.state.library.identifier, + annotations: annotations, + userId: viewModel.state.userId, + schemaController: schemaController + ) + self.perform(request: request) { [weak viewModel] error in + guard let error, let viewModel else { return } + + DDLogError("HtmlEpubReaderActionHandler: could not store annotations - \(error)") + + self.update(viewModel: viewModel) { state in + state.error = .cantAddAnnotations + } + } + + func parse(annotations: [[String: Any]], author: String, isAuthor: Bool) -> [HtmlEpubAnnotation] { + return annotations.compactMap { data -> HtmlEpubAnnotation? in + guard let id = data["id"] as? String, + let dateCreated = (data["dateCreated"] as? String).flatMap({ DateFormatter.iso8601WithFractionalSeconds.date(from: $0) }), + let dateModified = (data["dateModified"] as? String).flatMap({ DateFormatter.iso8601WithFractionalSeconds.date(from: $0) }), + let color = data["color"] as? String, + let comment = data["comment"] as? String, + let pageLabel = data["pageLabel"] as? String, + let position = data["position"] as? [String: Any], + let sortIndex = data["sortIndex"] as? String, + let text = data["text"] as? String, + let type = (data["type"] as? String).flatMap(AnnotationType.init), + let rawTags = data["tags"] as? [[String: Any]] + else { return nil } + let tags = rawTags.compactMap({ data -> Tag? in + guard let name = data["name"] as? String, + let color = data["color"] as? String + else { return nil } + return Tag(name: name, color: color) + }) + return HtmlEpubAnnotation( + key: id, + type: type, + pageLabel: pageLabel, + position: position, + author: author, + isAuthor: isAuthor, + color: color, + comment: comment, + text: text, + sortIndex: sortIndex, + dateModified: dateModified, + dateCreated: dateCreated, + tags: tags + ) + } + } + } + + private func load(in viewModel: ViewModel) { + do { + let data = try Data(contentsOf: viewModel.state.url) + let jsArrayData = try JSONSerialization.data(withJSONObject: [UInt8](data)) + guard let jsArrayString = String(data: jsArrayData, encoding: .utf8) else { + DDLogError("HtmlEpubReaderActionHandler: can't convert data to string") + return + } + let (sortedKeys, annotations, json, token, rawPage) = loadAnnotationsAndJson(in: viewModel) + let type: String + let page: HtmlEpubReaderState.DocumentData.Page? + + switch viewModel.state.url.pathExtension.lowercased() { + case "epub": + type = "epub" + page = .epub(cfi: rawPage) + + case "html", "htm": + type = "snapshot" + if let scrollYPercent = Double(rawPage) { + page = .html(scrollYPercent: scrollYPercent) + } else { + DDLogError("HtmlEPubReaderActionHandler: incompatible lastIndexPage stored for \(viewModel.state.key) - \(rawPage)") + page = nil + } + + default: + throw HtmlEpubReaderState.Error.incompatibleDocument + } + + let documentData = HtmlEpubReaderState.DocumentData(type: type, buffer: jsArrayString, annotationsJson: json, page: page) + self.update(viewModel: viewModel) { state in + state.sortedKeys = sortedKeys + state.annotations = annotations + state.documentData = documentData + state.notificationToken = token + state.changes = .annotations + } + } catch let error { + DDLogError("HtmlEpubReaderActionHandler: could not load document - \(error)") + } + } + + private func loadAnnotationsAndJson(in viewModel: ViewModel) -> ([String], [String: HtmlEpubAnnotation], String, NotificationToken?, String) { + do { + let pageIndexRequest = ReadDocumentDataDbRequest(attachmentKey: viewModel.state.key, libraryId: viewModel.state.library.identifier) + let pageIndex = try self.dbStorage.perform(request: pageIndexRequest, on: .main) + let annotationsRequest = ReadAnnotationsDbRequest(attachmentKey: viewModel.state.key, libraryId: viewModel.state.library.identifier) + let items = try self.dbStorage.perform(request: annotationsRequest, on: .main) + var sortedKeys: [String] = [] + var annotations: [String: HtmlEpubAnnotation] = [:] + var jsons: [[String: Any]] = [] + + for item in items { + guard let (annotation, json) = item.htmlEpubAnnotation else { continue } + jsons.append(json) + sortedKeys.append(annotation.key) + annotations[item.key] = annotation + } + + let jsonString = WebViewEncoder.encodeAsJSONForJavascript(jsons) + + let token = items.observe { [weak self, weak viewModel] change in + guard let self = self, let viewModel = viewModel else { return } + switch change { + case .update(let objects, let deletions, let insertions, let modifications): + self.update(objects: objects, deletions: deletions, insertions: insertions, modifications: modifications, viewModel: viewModel) + case .error, .initial: break + } + } + + return (sortedKeys, annotations, jsonString, token, pageIndex) + } catch let error { + DDLogError("HtmlEpubReaderActionHandler: can't load annotations - \(error)") + return ([], [:], "[]", nil, "") + } + } + + private func update(objects: Results, deletions: [Int], insertions: [Int], modifications: [Int], viewModel: ViewModel) { + DDLogInfo("HtmlEpubReaderActionHandler: annotations changed in database") + + // Get sorted database keys + var keys = viewModel.state.snapshotKeys ?? viewModel.state.sortedKeys + var annotations: [String: HtmlEpubAnnotation] = viewModel.state.annotations + var comments = viewModel.state.comments + var selectionDeleted = false + // Update database keys based on realm notification + var updatedKeys: [String] = [] + // Collect modified, deleted and inserted annotations to update the `Document` + var updatedPdfAnnotations: [[String: Any]] = [] + var deletedPdfAnnotations: [String] = [] + var insertedPdfAnnotations: [[String: Any]] = [] + + // Check which annotations changed and update Html/Epub + for index in modifications { + if index >= keys.count { + DDLogWarn( + "HtmlEpubReaderActionHandler: tried modifying index out of bounds! keys.count=\(keys.count); index=\(index); deletions=\(deletions); insertions=\(insertions); modifications=\(modifications)" + ) + continue + } + + let key = keys[index] + guard let item = objects.filter(.key(key)).first, let (annotation, json) = item.htmlEpubAnnotation else { continue } + + DDLogInfo("HtmlEpubReaderActionHandler: update Html/Epub annotation \(key)") + annotations[key] = annotation + updatedPdfAnnotations.append(json) + + if canUpdate(key: key, item: item, viewModel: viewModel) { + DDLogInfo("HtmlEpubReaderActionHandler: update sidebar key \(key)") + updatedKeys.append(key) + + if item.changeType == .sync { + // Update comment if it's remote sync change + DDLogInfo("HtmlEpubReaderActionHandler: update comment") + comments[key] = htmlAttributedStringConverter.convert(text: annotation.comment, baseAttributes: [.font: viewModel.state.commentFont]) + } + } + } + + var shouldCancelUpdate = false + + // Find Html/Epub annotations to be removed from document + for index in deletions.reversed() { + if index >= keys.count { + DDLogWarn( + "HtmlEpubReaderActionHandler: tried removing index out of bounds! keys.count=\(keys.count); index=\(index); deletions=\(deletions); insertions=\(insertions); modifications=\(modifications)" + ) + shouldCancelUpdate = true + break + } + + let key = keys.remove(at: index) + annotations[key] = nil + deletedPdfAnnotations.append(key) + DDLogInfo("HtmlEpubReaderActionHandler: delete key \(key)") + + if viewModel.state.selectedAnnotationKey == key { + DDLogInfo("HtmlEpubReaderActionHandler: deleted selected annotation") + selectionDeleted = true + } + } + + if shouldCancelUpdate { + return + } + + // Create annotations which need to be added to Html/Epub + for index in insertions { + if index > keys.count { + DDLogWarn("HtmlEpubReaderActionHandler: tried inserting index out of bounds! keys.count=\(keys.count); index=\(index); deletions=\(deletions); insertions=\(insertions); modifications=\(modifications)") + shouldCancelUpdate = true + break + } + + let item = objects[index] + + guard let (annotation, json) = item.htmlEpubAnnotation else { + DDLogWarn("HtmlEpubReaderActionHandler: tried adding invalid annotation") + shouldCancelUpdate = true + break + } + + keys.insert(item.key, at: index) + annotations[item.key] = annotation + DDLogInfo("HtmlEpubReaderActionHandler: insert key \(item.key)") + + switch item.changeType { + case .user: + break + + case .sync, .syncResponse: + insertedPdfAnnotations.append(json) + DDLogInfo("HtmlEpubReaderActionHandler: insert Html/Epub annotation") + } + } + + if shouldCancelUpdate { + return + } + + // Update state + self.update(viewModel: viewModel) { state in + if state.snapshotKeys == nil { + state.sortedKeys = keys + } else { + state.snapshotKeys = keys + state.sortedKeys = self.filteredKeys(from: keys, term: state.annotationSearchTerm, filter: state.annotationFilter, state: state) + } + state.annotations = annotations + state.documentUpdate = HtmlEpubReaderState.DocumentUpdate(deletions: deletedPdfAnnotations, insertions: insertedPdfAnnotations, modifications: updatedPdfAnnotations) + state.comments = comments + // Filter updated keys to include only keys that are actually available in `sortedKeys`. If filter/search is turned on and an item is edited so that it disappears from the filter/search, + // `updatedKeys` will try to update it while the key will be deleted from data source at the same time. + state.updatedAnnotationKeys = updatedKeys.filter({ state.sortedKeys.contains($0) }) + state.changes = .annotations + + // Update selection + if selectionDeleted { + self._select(data: nil, didSelectInDocument: true, state: &state) + } + + // Disable sidebar editing if there are no results + if (state.snapshotKeys ?? state.sortedKeys).isEmpty { + state.sidebarEditingEnabled = false + state.changes.insert(.sidebarEditing) + } + } + + func canUpdate(key: String, item: RItem, viewModel: ViewModel) -> Bool { + // If there was a sync type change, always update item + switch item.changeType { + case .sync: + // If sync happened and this item changed, always update item + return true + + case .syncResponse: + // This is a response to local changes being synced to backend, can be ignored + return false + + case .user: break + } + + // Check whether selected annotation's comment is being edited. + guard viewModel.state.selectedAnnotationCommentActive && viewModel.state.selectedAnnotationKey == key else { return true } + + // Check whether the comment actually changed. + let newComment = item.fields.filter(.key(FieldKeys.Item.Annotation.comment)).first?.value + let oldComment = viewModel.state.annotations[key]?.comment + return oldComment == newComment + } + } +} + +extension RItem { + fileprivate var htmlEpubAnnotation: (HtmlEpubAnnotation, [String: Any])? { + let tags = Array(self.tags.map({ typedTag in + let color: String? = (typedTag.tag?.color ?? "").isEmpty ? nil : typedTag.tag?.color + return Tag(name: typedTag.tag?.name ?? "", color: color ?? "") + })) + + var type: AnnotationType? + var position: [String: Any] = [:] + var text: String? + var sortIndex: String? + var pageLabel: String? + var comment: String? + var color: String? + var unknown: [String: String] = [:] + + for field in self.fields { + switch (field.key, field.baseKey) { + case (FieldKeys.Item.Annotation.Position.htmlEpubType, FieldKeys.Item.Annotation.position): + position[FieldKeys.Item.Annotation.Position.htmlEpubType] = field.value + + case (FieldKeys.Item.Annotation.Position.htmlEpubValue, FieldKeys.Item.Annotation.position): + position[FieldKeys.Item.Annotation.Position.htmlEpubValue] = field.value + + case (FieldKeys.Item.Annotation.type, nil): + type = AnnotationType(rawValue: field.value) + if type == nil { + DDLogError("HtmlEpubReaderActionHandler: invalid annotation type when creating annotation, type=\(field.value)") + } + + case (FieldKeys.Item.Annotation.text, nil): + text = field.value + + case (FieldKeys.Item.Annotation.sortIndex, nil): + sortIndex = field.value + + case (FieldKeys.Item.Annotation.pageLabel, nil): + pageLabel = field.value + + case (FieldKeys.Item.Annotation.comment, nil): + comment = field.value + + case (FieldKeys.Item.Annotation.color, nil): + color = field.value + + default: + unknown[field.key] = field.value + } + } + + guard let type, let sortIndex, !position.isEmpty else { + DDLogError("HtmlEpubReaderActionHandler: can't create html/epub annotation, type=\(String(describing: type));sortIndex=\(String(describing: sortIndex));position=\(position)") + return nil + } + + let json: [String: Any] = [ + "id": self.key, + "dateCreated": DateFormatter.iso8601WithFractionalSeconds.string(from: self.dateAdded), + "dateModified": DateFormatter.iso8601WithFractionalSeconds.string(from: self.dateModified), + "authorName": self.createdBy?.username ?? "", + "type": type.rawValue, + "text": text ?? "", + "sortIndex": sortIndex, + "pageLabel": pageLabel ?? "", + "comment": comment ?? "", + "color": color ?? "", + "position": position, + "tags": tags.map({ ["name": $0.name, "color": $0.color] }) + ] + let annotation = HtmlEpubAnnotation( + key: self.key, + type: type, + pageLabel: pageLabel ?? "", + position: position, + author: self.createdBy?.username ?? "", + isAuthor: true, + color: color ?? "", + comment: comment ?? "", + text: text, + sortIndex: sortIndex, + dateModified: self.dateModified, + dateCreated: self.dateAdded, + tags: tags + ) + + return (annotation, json) + } +} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubDocumentViewController.swift b/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubDocumentViewController.swift new file mode 100644 index 000000000..c7b595a91 --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubDocumentViewController.swift @@ -0,0 +1,257 @@ +// +// HtmlEpubDocumentViewController.swift +// Zotero +// +// Created by Michal Rentka on 05.09.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit +import WebKit + +import CocoaLumberjackSwift +import RxSwift + +class HtmlEpubDocumentViewController: UIViewController { + enum JSHandlers: String, CaseIterable { + case text = "textHandler" + case log = "logHandler" + } + + private let viewModel: ViewModel + private let disposeBag: DisposeBag + + private weak var webView: WKWebView! + private var webViewHandler: WebViewHandler! + weak var parentDelegate: HtmlEpubReaderContainerDelegate? + + init(viewModel: ViewModel) { + self.viewModel = viewModel + self.disposeBag = DisposeBag() + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = UIView() + self.view.backgroundColor = .systemBackground + } + + override func viewDidLoad() { + super.viewDidLoad() + + observeViewModel() + setupWebView() + load() + + func observeViewModel() { + viewModel.stateObservable + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { `self`, state in + self.process(state: state) + }) + .disposed(by: disposeBag) + } + + func setupWebView() { + let webView = WKWebView() + webView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(webView) + + NSLayoutConstraint.activate([ + self.view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: webView.topAnchor), + self.view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: webView.bottomAnchor), + self.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: webView.leadingAnchor), + self.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: webView.trailingAnchor) + ]) + self.webView = webView + self.webViewHandler = WebViewHandler(webView: webView, javascriptHandlers: JSHandlers.allCases.map({ $0.rawValue })) + self.webViewHandler.receivedMessageHandler = { [weak self] handler, message in + self?.process(handler: handler, message: message) + } + } + + func load() { + guard let readerUrl = Bundle.main.url(forResource: "view", withExtension: "html", subdirectory: "Bundled/reader") else { + DDLogError("HtmlEpubReaderViewController: can't load reader view.html") + return + } + self.webViewHandler.load(fileUrl: readerUrl) + .subscribe() + .disposed(by: self.disposeBag) + } + } + + // MARK: - Actions + + private func process(state: HtmlEpubReaderState) { + if let data = state.documentData { + load(documentData: data) + return + } + + if let update = state.documentUpdate { + updateView(modifications: update.modifications, insertions: update.insertions, deletions: update.deletions) + } + + if let term = state.documentSearchTerm { + search(term: term) + } + + if let key = state.focusDocumentKey { + selectInDocument(key: key) + } + + if state.changes.contains(.activeTool) || state.changes.contains(.toolColor) { + let tool = state.activeTool + let color = tool.flatMap({ state.toolColors[$0] }) + + if let tool, let color { + set(tool: (tool, color)) + } else { + set(tool: nil) + } + } + + func set(tool data: (AnnotationTool, UIColor)?) { + guard let (tool, color) = data else { + self.webViewHandler.call(javascript: "clearTool();") + .subscribe() + .disposed(by: self.disposeBag) + return + } + + let toolName: String + switch tool { + case .highlight: + toolName = "highlight" + + case .note: + toolName = "note" + + case .eraser, .image, .ink: + return + } + + self.webViewHandler.call(javascript: "setTool({ type: '\(toolName)', color: '\(color.hexString)' });") + .subscribe() + .disposed(by: self.disposeBag) + } + + func search(term: String) { + webViewHandler.call(javascript: "search({ term: \(WebViewEncoder.encodeForJavascript(term.data(using: .utf8))) });") + .observe(on: MainScheduler.instance) + .subscribe(with: self, onFailure: { _, error in + DDLogError("HtmlEpubReaderViewController: searching document failed - \(error)") + }) + .disposed(by: self.disposeBag) + } + + func selectInDocument(key: String) { + webViewHandler.call(javascript: "select({ key: '\(key)' });") + .observe(on: MainScheduler.instance) + .subscribe(with: self, onFailure: { _, error in + DDLogError("HtmlEpubReaderViewController: navigating to \(key) failed - \(error)") + }) + .disposed(by: self.disposeBag) + } + + func updateView(modifications: [[String: Any]], insertions: [[String: Any]], deletions: [String]) { + let encodedDeletions = WebViewEncoder.encodeAsJSONForJavascript(deletions) + let encodedInsertions = WebViewEncoder.encodeAsJSONForJavascript(insertions) + let encodedModifications = WebViewEncoder.encodeAsJSONForJavascript(modifications) + webViewHandler.call(javascript: "updateAnnotations({ deletions: \(encodedDeletions), insertions: \(encodedInsertions), modifications: \(encodedModifications)});") + .observe(on: MainScheduler.instance) + .subscribe(with: self, onFailure: { _, error in + DDLogError("HtmlEpubReaderViewController: updating document failed - \(error)") + }) + .disposed(by: self.disposeBag) + } + + func load(documentData data: HtmlEpubReaderState.DocumentData) { + DDLogInfo("HtmlEpubDocumentViewController: try creating view for \(data.type); page = \(String(describing: data.page))") + var javascript = "createView({ type: '\(data.type)', buf: \(data.buffer), annotations: \(data.annotationsJson)" + if let page = data.page { + switch page { + case .html(let scrollYPercent): + javascript += ", viewState: {scrollYPercent: \(scrollYPercent), scale: 1}" + + case .epub(let cfi): + javascript += ", viewState: {cfi: '\(cfi)'}" + } + } + javascript += "});" + + webViewHandler.call(javascript: javascript) + .observe(on: MainScheduler.instance) + .subscribe(with: self, onFailure: { _, error in + DDLogError("HtmlEpubReaderViewController: loading document failed - \(error)") + }) + .disposed(by: self.disposeBag) + } + } + + private func process(handler: String, message: Any) { + switch handler { + case JSHandlers.log.rawValue: + DDLogInfo("HtmlEpubReaderViewController: JSLOG \(message)") + + case JSHandlers.text.rawValue: + guard let data = message as? [String: Any], let event = data["event"] as? String else { + DDLogWarn("HtmlEpubReaderViewController: unknown message - \(message)") + return + } + + DDLogInfo("HtmlEpubReaderViewController: \(event)") + + switch event { + case "onInitialized": + self.viewModel.process(action: .loadDocument) + + case "onSaveAnnotations": + guard let params = data["params"] as? [String: Any] else { + DDLogWarn("HtmlEpubReaderViewController: event \(event) missing params - \(message)") + return + } + DDLogInfo("HtmlEpubReaderViewController: \(params)") + self.viewModel.process(action: .saveAnnotations(params)) + + case "onSetAnnotationPopup": + guard let params = data["params"] as? [String: Any] else { + DDLogWarn("HtmlEpubReaderViewController: event \(event) missing params - \(message)") + return + } + + if params.isEmpty { + self.viewModel.process(action: .deselectSelectedAnnotation) + return + } + + guard let rectArray = params["rect"] as? [CGFloat], let key = (params["annotation"] as? [String: Any])?["id"] as? String else { + DDLogError("HtmlEpubReaderViewController: incorrect params for document selection - \(params)") + return + } + + let navigationBarInset = (self.parentDelegate?.statusBarHeight ?? 0) + (self.parentDelegate?.navigationBarHeight ?? 0) + let rect = CGRect(x: rectArray[0], y: rectArray[1] + navigationBarInset, width: rectArray[2] - rectArray[0], height: rectArray[3] - rectArray[1]) + self.viewModel.process(action: .selectAnnotationFromDocument(key: key, rect: rect)) + + case "onChangeViewState": + guard let params = data["params"] as? [String: Any] else { + DDLogWarn("HtmlEpubReaderViewController: event \(event) missing params - \(message)") + return + } + self.viewModel.process(action: .setViewState(params)) + + default: + break + } + + default: + break + } + } +} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubReaderViewController.swift b/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubReaderViewController.swift new file mode 100644 index 000000000..b5b2996fd --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubReaderViewController.swift @@ -0,0 +1,566 @@ +// +// HtmlEpubReaderViewController.swift +// Zotero +// +// Created by Michal Rentka on 24.08.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import CocoaLumberjackSwift +import RxSwift + +protocol HtmlEpubReaderContainerDelegate: AnyObject { + var statusBarHeight: CGFloat { get } + var navigationBarHeight: CGFloat { get } + var isSidebarVisible: Bool { get } +} + +class HtmlEpubReaderViewController: UIViewController { + private let viewModel: ViewModel + private let disposeBag: DisposeBag + private static let sidebarButtonTag = 7 + + private weak var documentController: HtmlEpubDocumentViewController? + private weak var documentTop: NSLayoutConstraint! + private weak var documentLeft: NSLayoutConstraint! + private weak var annotationToolbarController: AnnotationToolbarViewController! + private var annotationToolbarHandler: AnnotationToolbarHandler! + private weak var sidebarController: HtmlEpubSidebarViewController! + private weak var sidebarLeft: NSLayoutConstraint! + var navigationBarHeight: CGFloat { + return self.navigationController?.navigationBar.frame.height ?? 0.0 + } + private(set) var isCompactWidth: Bool + var statusBarHeight: CGFloat + weak var coordinatorDelegate: (HtmlEpubReaderCoordinatorDelegate&HtmlEpubSidebarCoordinatorDelegate)? + @CodableUserDefault( + key: "HtmlEpubReaderToolbarState", + defaultValue: AnnotationToolbarHandler.State(position: .leading, visible: true), + encoder: Defaults.jsonEncoder, + decoder: Defaults.jsonDecoder + ) + var toolbarState: AnnotationToolbarHandler.State + @UserDefault(key: "HtmlEpubReaderStatusBarVisible", defaultValue: true) + var statusBarVisible: Bool { + didSet { + (self.navigationController as? NavigationViewController)?.statusBarVisible = self.statusBarVisible + } + } + var isSidebarVisible: Bool { return self.sidebarLeft?.constant == 0 } + private lazy var toolbarButton: UIBarButtonItem = { + let checkbox = CheckboxButton( + image: UIImage(systemName: "pencil.and.outline", withConfiguration: UIImage.SymbolConfiguration(scale: .large))!, + contentInsets: NSDirectionalEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6) + ) + checkbox.adjustsImageWhenHighlighted = false + checkbox.scalesLargeContentImage = true + checkbox.layer.cornerRadius = 4 + checkbox.layer.masksToBounds = true + checkbox.selectedBackgroundColor = Asset.Colors.zoteroBlue.color + checkbox.selectedTintColor = .white + checkbox.deselectedTintColor = Asset.Colors.zoteroBlueWithDarkMode.color + checkbox.isSelected = toolbarState.visible + checkbox.rx.controlEvent(.touchUpInside) + .subscribe(onNext: { [weak self, weak checkbox] _ in + guard let self = self, let checkbox = checkbox else { return } + checkbox.isSelected = !checkbox.isSelected + self.annotationToolbarHandler.set(hidden: !checkbox.isSelected, animated: true) + }) + .disposed(by: disposeBag) + let barButton = UIBarButtonItem(customView: checkbox) + barButton.accessibilityLabel = L10n.Accessibility.Pdf.toggleAnnotationToolbar + barButton.title = L10n.Accessibility.Pdf.toggleAnnotationToolbar + barButton.largeContentSizeImage = UIImage(systemName: "pencil.and.outline", withConfiguration: UIImage.SymbolConfiguration(scale: .large)) + return barButton + }() + private lazy var settingsButton: UIBarButtonItem = { + let settings = UIBarButtonItem(image: UIImage(systemName: "gearshape"), style: .plain, target: nil, action: nil) + settings.accessibilityLabel = L10n.Accessibility.Pdf.settings + settings.title = L10n.Accessibility.Pdf.settings + settings.rx.tap + .subscribe(onNext: { [weak self, weak settings] _ in + guard let self, let settings else { return } + self.showSettings(sender: settings) + }) + .disposed(by: disposeBag) + return settings + }() + + // MARK: - Lifecycle + + init(viewModel: ViewModel, compactSize: Bool) { + self.viewModel = viewModel + self.isCompactWidth = compactSize + self.disposeBag = DisposeBag() + self.statusBarHeight = UIApplication + .shared + .connectedScenes + .filter({ $0.activationState == .foregroundActive }) + .compactMap({ $0 as? UIWindowScene }) + .first? + .windows + .first(where: { $0.isKeyWindow })? + .windowScene? + .statusBarManager? + .statusBarFrame + .height ?? 0 + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = UIView() + self.view.backgroundColor = .systemBackground + } + + override func viewDidLoad() { + super.viewDidLoad() + + observeViewModel() + setupNavigationBar() + setupSearch() + setupViews() + navigationController?.overrideUserInterfaceStyle = viewModel.state.settings.appearance.userInterfaceStyle + navigationItem.rightBarButtonItems = [settingsButton, toolbarButton] + + func observeViewModel() { + viewModel.stateObservable + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { `self`, state in + self.process(state: state) + }) + .disposed(by: disposeBag) + } + + func setupNavigationBar() { + let closeButton = UIBarButtonItem(image: UIImage(systemName: "chevron.left"), style: .plain, target: nil, action: nil) + closeButton.title = L10n.close + closeButton.accessibilityLabel = L10n.close + closeButton.rx.tap.subscribe(with: self, onNext: { _, _ in self.close() }).disposed(by: disposeBag) + + let sidebarButton = UIBarButtonItem(image: UIImage(systemName: "sidebar.left"), style: .plain, target: nil, action: nil) + setupAccessibility(forSidebarButton: sidebarButton) + sidebarButton.tag = Self.sidebarButtonTag + sidebarButton.rx.tap.subscribe(with: self, onNext: { `self`, _ in self.toggleSidebar(animated: true) }).disposed(by: disposeBag) + + navigationItem.leftBarButtonItems = [closeButton, sidebarButton] + } + + func setupSearch() { + let searchController = UISearchController() + searchController.obscuresBackgroundDuringPresentation = false + searchController.hidesNavigationBarDuringPresentation = false + searchController.searchBar.placeholder = "Search Document" + searchController.searchBar.autocapitalizationType = .none + + searchController.searchBar + .rx + .text + .observe(on: MainScheduler.instance) + .skip(1) + .debounce(.milliseconds(150), scheduler: MainScheduler.instance) + .subscribe(onNext: { [weak self] text in + self?.viewModel.process(action: .searchDocument(text ?? "")) + }) + .disposed(by: disposeBag) + + searchController.searchBar + .rx + .cancelButtonClicked + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + self?.viewModel.process(action: .searchDocument("")) + }) + .disposed(by: disposeBag) + + navigationItem.searchController = searchController + } + + func setupViews() { + let documentController = HtmlEpubDocumentViewController(viewModel: viewModel) + documentController.parentDelegate = self + documentController.view.translatesAutoresizingMaskIntoConstraints = false + + let annotationToolbar = AnnotationToolbarViewController(tools: [.highlight, .note], undoRedoEnabled: false, size: navigationBarHeight) + annotationToolbar.delegate = self + + let sidebarController = HtmlEpubSidebarViewController(viewModel: viewModel) + sidebarController.parentDelegate = self + sidebarController.coordinatorDelegate = coordinatorDelegate + sidebarController.view.translatesAutoresizingMaskIntoConstraints = false + + let separator = UIView() + separator.translatesAutoresizingMaskIntoConstraints = false + separator.backgroundColor = Asset.Colors.annotationSidebarBorderColor.color + + add(controller: documentController) + add(controller: annotationToolbar) + add(controller: sidebarController) + view.addSubview(documentController.view) + view.addSubview(annotationToolbar.view) + view.addSubview(sidebarController.view) + view.addSubview(separator) + + let documentLeftConstraint = documentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor) + let sidebarLeftConstraint = sidebarController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: -PDFReaderLayout.sidebarWidth) + let documentTopConstraint = documentController.view.topAnchor.constraint(equalTo: view.topAnchor) + + NSLayoutConstraint.activate([ + documentTopConstraint, + view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: documentController.view.bottomAnchor), + view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: documentController.view.trailingAnchor), + sidebarController.view.topAnchor.constraint(equalTo: view.topAnchor), + sidebarController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + sidebarController.view.widthAnchor.constraint(equalToConstant: PDFReaderLayout.sidebarWidth), + sidebarLeftConstraint, + separator.widthAnchor.constraint(equalToConstant: PDFReaderLayout.separatorWidth), + separator.leadingAnchor.constraint(equalTo: sidebarController.view.trailingAnchor), + separator.topAnchor.constraint(equalTo: view.topAnchor), + separator.bottomAnchor.constraint(equalTo: view.bottomAnchor), + documentLeftConstraint + ]) + + self.documentController = documentController + self.sidebarController = sidebarController + self.annotationToolbarController = annotationToolbar + self.documentTop = documentTopConstraint + self.documentLeft = documentLeftConstraint + self.sidebarLeft = sidebarLeftConstraint + self.annotationToolbarHandler = AnnotationToolbarHandler(controller: annotationToolbar, delegate: self) + self.annotationToolbarHandler.performInitialLayout() + + func add(controller: UIViewController) { + controller.willMove(toParent: self) + addChild(controller) + controller.didMove(toParent: self) + } + } + } + + override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + annotationToolbarHandler.viewIsAppearing(documentIsLocked: false) + } + + deinit { + DDLogInfo("HtmlEpubReaderViewController deinitialized") + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if (documentController?.view.frame.width ?? 0) < AnnotationToolbarHandler.minToolbarWidth && toolbarState.visible && toolbarState.position == .top { + closeAnnotationToolbar() + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + isCompactWidth = UIDevice.current.isCompactWidth(size: size) + + guard viewIfLoaded != nil else { return } + + coordinator.animate(alongsideTransition: { _ in + self.statusBarHeight = self.view.safeAreaInsets.top - (self.navigationController?.isNavigationBarHidden == true ? 0 : self.navigationBarHeight) + self.annotationToolbarHandler.viewWillTransitionToNewSize() + }, completion: nil) + } + + // MARK: - State + + private func process(state: HtmlEpubReaderState) { + if let error = state.error { + show(error: error) + } + + if state.changes.contains(.toolColor), let color = state.activeTool.flatMap({ state.toolColors[$0] }) { + annotationToolbarController.set(activeColor: color) + } + + if state.changes.contains(.activeTool) { + select(activeTool: state.activeTool) + } + + if state.changes.contains(.settings) { + navigationController?.overrideUserInterfaceStyle = state.settings.appearance.userInterfaceStyle + } + + handleAnnotationPopover() + + func select(activeTool tool: AnnotationTool?) { + annotationToolbarController.deselectActiveTool() + if let tool, let color = viewModel.state.toolColors[tool] { + annotationToolbarController.set(selected: true, to: tool, color: color) + } + } + + func show(error: HtmlEpubReaderState.Error) { + } + + func handleAnnotationPopover() { + if let key = state.selectedAnnotationKey { + if !isSidebarVisible, let rect = state.selectedAnnotationRect { + let observable = coordinatorDelegate?.showAnnotationPopover( + viewModel: viewModel, + sourceRect: rect, + popoverDelegate: self, + userInterfaceStyle: viewModel.state.settings.appearance.userInterfaceStyle + ) + observe(key: key, popoverObservable: observable) + } + } else if navigationController?.presentedViewController is AnnotationPopover || + (navigationController?.presentedViewController as? UINavigationController)?.topViewController is AnnotationPopover { + navigationController?.dismiss(animated: true) + } + } + + func observe(key: String, popoverObservable observable: PublishSubject?) { + guard let observable else { return } + observable.subscribe(with: self) { `self`, state in + if state.changes.contains(.color) { + self.viewModel.process(action: .setColor(key: key, color: state.color)) + } + if state.changes.contains(.comment) { + self.viewModel.process(action: .setComment(key: key, comment: state.comment)) + } + if state.changes.contains(.deletion) { + self.viewModel.process(action: .removeAnnotation(key)) + } + if state.changes.contains(.tags) { + self.viewModel.process(action: .setTags(key: key, tags: state.tags)) + } + if state.changes.contains(.pageLabel) || state.changes.contains(.highlight) { + self.viewModel.process(action: + .updateAnnotationProperties( + key: key, + color: state.color, + lineWidth: state.lineWidth, + pageLabel: state.pageLabel, + updateSubsequentLabels: state.updateSubsequentLabels, + highlightText: state.highlightText + ) + ) + } + } + .disposed(by: disposeBag) + } + } + + // MARK: - Actions + + private func showSettings(sender: UIBarButtonItem) { + guard let viewModel = coordinatorDelegate?.showSettings(with: viewModel.state.settings, sender: sender) else { return } + viewModel.stateObservable + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { `self`, state in + let settings = HtmlEpubSettings(appearance: state.appearance, idleTimerDisabled: state.idleTimerDisabled) + self.viewModel.process(action: .setSettings(settings)) + }) + .disposed(by: disposeBag) + } + + private func close() { + viewModel.process(action: .changeIdleTimerDisabled(false)) + navigationController?.presentingViewController?.dismiss(animated: true) + } + + private func toggleSidebar(animated: Bool) { + let shouldShow = !isSidebarVisible + + if toolbarState.position == .leading { + if shouldShow { + annotationToolbarHandler.disableLeadingSafeConstraint() + } else { + annotationToolbarHandler.enableLeadingSafeConstraint() + } + } + // If the layout is compact, show annotation sidebar above pdf document. + if !isCompactWidth { + documentLeft.constant = shouldShow ? PDFReaderLayout.sidebarWidth : 0 + } else if shouldShow && toolbarState.visible { + closeAnnotationToolbar() + } + sidebarLeft.constant = shouldShow ? 0 : -PDFReaderLayout.sidebarWidth + + if let button = navigationItem.leftBarButtonItems?.first(where: { $0.tag == Self.sidebarButtonTag }) { + setupAccessibility(forSidebarButton: button) + } + + if !animated { + sidebarController.view.isHidden = !shouldShow + annotationToolbarController.prepareForSizeChange() + view.layoutIfNeeded() + annotationToolbarController.sizeDidChange() + + if !shouldShow { + view.endEditing(true) + } + return + } + + if shouldShow { + sidebarController.view.isHidden = false + } else { + view.endEditing(true) + } + + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 5, options: [.curveEaseOut], + animations: { + self.annotationToolbarController.prepareForSizeChange() + self.view.layoutIfNeeded() + self.annotationToolbarController.sizeDidChange() + }, + completion: { finished in + guard finished else { return } + if !shouldShow { + self.sidebarController.view.isHidden = true + } + }) + } + + private func setupAccessibility(forSidebarButton button: UIBarButtonItem) { + button.accessibilityLabel = isSidebarVisible ? L10n.Accessibility.Pdf.sidebarClose : L10n.Accessibility.Pdf.sidebarOpen + button.title = isSidebarVisible ? L10n.Accessibility.Pdf.sidebarClose : L10n.Accessibility.Pdf.sidebarOpen + } +} + +extension HtmlEpubReaderViewController: AnnotationToolbarHandlerDelegate { + var isNavigationBarHidden: Bool { + navigationController?.navigationBar.isHidden ?? false + } + + var isSidebarHidden: Bool { + return !isSidebarVisible + } + + var containerView: UIView { + return view + } + + var documentView: UIView { + return view + } + + var toolbarLeadingAnchor: NSLayoutXAxisAnchor { + return sidebarController.view.trailingAnchor + } + + var toolbarLeadingSafeAreaAnchor: NSLayoutXAxisAnchor { + return view.safeAreaLayoutGuide.leadingAnchor + } + + func layoutIfNeeded() { + view.layoutIfNeeded() + } + + func setNeedsLayout() { + view.setNeedsLayout() + } + + func hideSidebarIfNeeded(forPosition position: AnnotationToolbarHandler.State.Position, isToolbarSmallerThanMinWidth: Bool, animated: Bool) { + guard isSidebarVisible && (position == .pinned || (position == .top && isToolbarSmallerThanMinWidth)) else { return } + toggleSidebar(animated: animated) + } + + func setNavigationBar(hidden: Bool, animated: Bool) { + navigationController?.setNavigationBarHidden(hidden, animated: animated) + } + + func setNavigationBar(alpha: CGFloat) { + navigationController?.navigationBar.alpha = alpha + } + + func setDocumentInterface(hidden: Bool) { + } + + func topDidChange(forToolbarState state: AnnotationToolbarHandler.State) { + let (statusBarOffset, _, totalOffset) = annotationToolbarHandler.topOffsets(statusBarVisible: statusBarVisible) + + if !state.visible { + documentTop.constant = totalOffset + return + } + + switch state.position { + case .pinned: + documentTop.constant = statusBarOffset + annotationToolbarController.size + + case .top: + documentTop.constant = totalOffset + annotationToolbarController.size + + case .trailing, .leading: + documentTop.constant = totalOffset + } + } + + func updateStatusBar() { + navigationController?.setNeedsStatusBarAppearanceUpdate() + setNeedsStatusBarAppearanceUpdate() + } +} + +extension HtmlEpubReaderViewController: AnnotationToolbarDelegate { + var rotation: AnnotationToolbarViewController.Rotation { + return .horizontal + } + + var activeAnnotationTool: AnnotationTool? { + return .highlight + } + + var canUndo: Bool { + return false + } + + var canRedo: Bool { + return false + } + + var maxAvailableToolbarSize: CGFloat { + return view.frame.width + } + + func toggle(tool: AnnotationTool, options: AnnotationToolOptions) { + viewModel.process(action: .toggleTool(tool)) + } + + func showToolOptions(sender: SourceView) { + guard let tool = viewModel.state.activeTool else { return } + let colorHex = viewModel.state.toolColors[tool]?.hexString + + coordinatorDelegate?.showToolSettings( + tool: tool, + colorHex: colorHex, + sizeValue: nil, + sender: sender, + userInterfaceStyle: viewModel.state.settings.appearance.userInterfaceStyle + ) { [weak self] newColor, newSize in + self?.viewModel.process(action: .setToolOptions(color: newColor, size: newSize.flatMap(CGFloat.init), tool: tool)) + } + } + + func closeAnnotationToolbar() { + (toolbarButton.customView as? CheckboxButton)?.isSelected = false + annotationToolbarHandler.set(hidden: true, animated: true) + } + + func performUndo() { + } + + func performRedo() { + } +} + +extension HtmlEpubReaderViewController: UIPopoverPresentationControllerDelegate { + func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { + viewModel.process(action: .deselectSelectedAnnotation) + } +} + +extension HtmlEpubReaderViewController: HtmlEpubReaderContainerDelegate {} diff --git a/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubSidebarViewController.swift b/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubSidebarViewController.swift new file mode 100644 index 000000000..ec2f940cf --- /dev/null +++ b/Zotero/Scenes/Detail/HTML:EPUB/Views/HtmlEpubSidebarViewController.swift @@ -0,0 +1,432 @@ +// +// HtmlEpubSidebarViewController.swift +// Zotero +// +// Created by Michal Rentka on 05.10.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import RxSwift + +class HtmlEpubSidebarViewController: UIViewController { + private static let cellId = "AnnotationCell" + private let viewModel: ViewModel + private let disposeBag: DisposeBag + + private weak var tableView: UITableView! + private weak var toolbarContainer: UIView! + private weak var toolbar: UIToolbar! + private weak var deleteBarButton: UIBarButtonItem? + private var dataSource: TableViewDiffableDataSource! + private var searchController: UISearchController! + weak var coordinatorDelegate: HtmlEpubSidebarCoordinatorDelegate? + weak var parentDelegate: HtmlEpubReaderContainerDelegate? + + init(viewModel: ViewModel) { + self.viewModel = viewModel + self.disposeBag = DisposeBag() + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = .systemGray6 + setupViews() + setupSearchController() + setupDataSource() + setupObserving() + setupToolbar( + filterEnabled: !viewModel.state.annotations.isEmpty, + filterOn: (viewModel.state.annotationFilter != nil), + editingEnabled: viewModel.state.sidebarEditingEnabled, + deletionEnabled: viewModel.state.deletionEnabled + ) + + func setupObserving() { + viewModel.stateObservable + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { `self`, state in + self.update(state: state) + }) + .disposed(by: disposeBag) + } + + func setupSearchController() { + let insets = UIEdgeInsets( + top: PDFReaderLayout.searchBarVerticalInset, + left: PDFReaderLayout.annotationLayout.horizontalInset, + bottom: PDFReaderLayout.searchBarVerticalInset - PDFReaderLayout.cellSelectionLineWidth, + right: PDFReaderLayout.annotationLayout.horizontalInset + ) + + var frame = tableView.frame + frame.size.height = 65 + + let searchBar = SearchBar(frame: frame, insets: insets, cornerRadius: 10) + searchBar.text + .observe(on: MainScheduler.instance) + .debounce(.milliseconds(150), scheduler: MainScheduler.instance) + .subscribe(with: self, onNext: { `self`, text in + self.viewModel.process(action: .searchAnnotations(text)) + }) + .disposed(by: disposeBag) + tableView.tableHeaderView = searchBar + } + + func setupViews() { + let tableView = UITableView(frame: self.view.bounds, style: .plain) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.separatorStyle = .none + tableView.backgroundColor = .systemGray6 + tableView.backgroundView?.backgroundColor = .systemGray6 + tableView.register(AnnotationCell.self, forCellReuseIdentifier: Self.cellId) + tableView.allowsMultipleSelectionDuringEditing = true + self.view.addSubview(tableView) + self.tableView = tableView + + let toolbarContainer = UIView() + toolbarContainer.isHidden = !viewModel.state.library.metadataEditable + toolbarContainer.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(toolbarContainer) + self.toolbarContainer = toolbarContainer + + let toolbar = UIToolbar() + toolbarContainer.backgroundColor = toolbar.backgroundColor + toolbar.translatesAutoresizingMaskIntoConstraints = false + toolbarContainer.addSubview(toolbar) + self.toolbar = toolbar + + NSLayoutConstraint.activate([ + self.view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: tableView.topAnchor), + tableView.bottomAnchor.constraint(equalTo: toolbarContainer.topAnchor), + self.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: tableView.leadingAnchor), + self.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: tableView.trailingAnchor), + toolbarContainer.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + toolbarContainer.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + toolbarContainer.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + toolbar.topAnchor.constraint(equalTo: toolbarContainer.topAnchor), + toolbar.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor), + toolbar.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + func setupDataSource() { + dataSource = TableViewDiffableDataSource(tableView: self.tableView, cellProvider: { [weak self] tableView, indexPath, key in + let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellId, for: indexPath) + + if let self, let cell = cell as? AnnotationCell, let annotation = self.viewModel.state.annotations[key] { + cell.contentView.backgroundColor = self.view.backgroundColor + setup(cell: cell, with: annotation, state: self.viewModel.state) + } + + return cell + }) + + dataSource.canEditRow = { _ in + return true + } + dataSource.commitEditingStyle = { [weak self] editingStyle, indexPath in + guard let self, editingStyle == .delete, let key = self.dataSource.itemIdentifier(for: indexPath) else { return } + self.viewModel.process(action: .removeAnnotation(key)) + } + } + + func setup(cell: AnnotationCell, with annotation: HtmlEpubAnnotation, state: HtmlEpubReaderState) { + let selected = annotation.key == state.selectedAnnotationKey + let comment = AnnotationView.Comment(attributedString: loadAttributedComment(for: annotation), isActive: state.selectedAnnotationCommentActive) + + cell.setup( + with: annotation, + comment: comment, + selected: selected, + availableWidth: PDFReaderLayout.sidebarWidth, + library: state.library, + isEditing: false, + currentUserId: state.userId, + state: state + ) + let actionSubscription = cell.actionPublisher.subscribe(onNext: { [weak self] action in + self?.perform(action: action, annotation: annotation) + }) + _ = cell.disposeBag?.insert(actionSubscription) + } + + func loadAttributedComment(for annotation: HtmlEpubAnnotation) -> NSAttributedString? { + let comment = annotation.comment + + guard !comment.isEmpty else { return nil } + + if let attributedComment = self.viewModel.state.comments[annotation.key] { + return attributedComment + } + + viewModel.process(action: .parseAndCacheComment(key: annotation.key, comment: comment)) + return viewModel.state.comments[annotation.key] + } + } + + func update(state: HtmlEpubReaderState) { + reloadIfNeeded(for: state) { [weak self] in + guard let self else { return } + + if state.changes.contains(.filter) || state.changes.contains(.annotations) || state.changes.contains(.sidebarEditing) { + setupToolbar( + filterEnabled: !state.annotations.isEmpty, + filterOn: (state.annotationFilter != nil), + editingEnabled: state.sidebarEditingEnabled, + deletionEnabled: state.deletionEnabled + ) + } + + if state.changes.contains(.sidebarEditingSelection) { + deleteBarButton?.isEnabled = state.deletionEnabled + } + } + + /// Reloads tableView if needed, based on new state. Calls completion either when reloading finished or when there was no reload. + /// - parameter state: Current state. + /// - parameter completion: Called after reload was performed or even if there was no reload. + func reloadIfNeeded(for state: HtmlEpubReaderState, completion: @escaping () -> Void) { + if state.changes.contains(.annotations) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(state.sortedKeys) + if let keys = state.updatedAnnotationKeys { + snapshot.reloadItems(keys) + } + + let isVisible = parentDelegate?.isSidebarVisible ?? false + + if state.changes.contains(.sidebarEditing) { + tableView.setEditing(state.sidebarEditingEnabled, animated: isVisible) + } + dataSource.apply(snapshot, animatingDifferences: isVisible, completion: completion) + + return + } + +// if state.changes.contains(.interfaceStyle) { +// var snapshot = self.dataSource.snapshot() +// snapshot.reloadSections([0]) +// self.dataSource.apply(snapshot, animatingDifferences: false, completion: completion) +// return +// } + + if state.changes.contains(.selection) || state.changes.contains(.activeComment) { + if let keys = state.updatedAnnotationKeys { + var snapshot = dataSource.snapshot() + snapshot.reloadItems(keys) + dataSource.apply(snapshot, animatingDifferences: false) + } + + updateCellHeight() + focusSelectedCell() + + if state.changes.contains(.sidebarEditing) { + let isVisible = parentDelegate?.isSidebarVisible ?? false + tableView.setEditing(state.sidebarEditingEnabled, animated: isVisible) + } + + completion() + + return + } + + if state.changes.contains(.sidebarEditing) { + tableView.setEditing(state.sidebarEditingEnabled, animated: true) + } + + completion() + } + } + + func perform(action: AnnotationView.Action, annotation: HtmlEpubAnnotation) { + let state = viewModel.state + + guard state.library.metadataEditable else { return } + + switch action { + case .tags: + guard annotation.isAuthor else { return } + let selected = Set(annotation.tags.map({ $0.name })) + coordinatorDelegate?.showTagPicker(libraryId: state.library.identifier, selected: selected, userInterfaceStyle: viewModel.state.settings.appearance.userInterfaceStyle, picked: { [weak self] tags in + self?.viewModel.process(action: .setTags(key: annotation.key, tags: tags)) + }) + + case .options(let sender): + guard let sender else { return } + let key = annotation.key + coordinatorDelegate?.showCellOptions( + for: annotation, + userId: viewModel.state.userId, + library: viewModel.state.library, + sender: sender, + userInterfaceStyle: viewModel.state.settings.appearance.userInterfaceStyle, + saveAction: { [weak self] color, lineWidth, pageLabel, updateSubsequentLabels, highlightText in + self?.viewModel.process( + action: .updateAnnotationProperties( + key: key, + color: color, + lineWidth: lineWidth, + pageLabel: pageLabel, + updateSubsequentLabels: updateSubsequentLabels, + highlightText: highlightText + ) + ) + }, + deleteAction: { [weak self] in + self?.viewModel.process(action: .removeAnnotation(key)) + } + ) + + case .setComment(let comment): + viewModel.process(action: .setComment(key: annotation.key, comment: comment)) + + case .reloadHeight: + updateCellHeight() + focusSelectedCell() + + case .setCommentActive(let isActive): + viewModel.process(action: .setCommentActive(isActive)) + + case .done: + break // Done button doesn't appear here + } + } + + /// Updates tableView layout in case any cell changed height. + private func updateCellHeight() { + UIView.setAnimationsEnabled(false) + tableView.beginUpdates() + tableView.endUpdates() + UIView.setAnimationsEnabled(true) + } + + /// Scrolls to selected cell if it's not visible. + private func focusSelectedCell() { + guard !viewModel.state.sidebarEditingEnabled, let indexPath = tableView.indexPathForSelectedRow else { return } + + let cellFrame = tableView.rectForRow(at: indexPath) + let cellBottom = cellFrame.maxY - tableView.contentOffset.y + let tableViewBottom = tableView.superview!.bounds.maxY - tableView.contentInset.bottom + let safeAreaTop = tableView.superview!.safeAreaInsets.top + + // Scroll either when cell bottom is below keyboard or cell top is not visible on screen + if cellBottom > tableViewBottom || cellFrame.minY < (safeAreaTop + self.tableView.contentOffset.y) { + // Scroll to top if cell is smaller than visible screen, so that it's fully visible, otherwise scroll to bottom. + let position: UITableView.ScrollPosition = cellFrame.height + safeAreaTop < tableViewBottom ? .top : .bottom + tableView.scrollToRow(at: indexPath, at: position, animated: false) + } + } + + private func setupToolbar(filterEnabled: Bool, filterOn: Bool, editingEnabled: Bool, deletionEnabled: Bool) { + guard !toolbarContainer.isHidden else { return } + + var items: [UIBarButtonItem] = [] + items.append(UIBarButtonItem(systemItem: .flexibleSpace, primaryAction: nil, menu: nil)) + + if editingEnabled { + let delete = UIBarButtonItem(title: L10n.delete, style: .plain, target: nil, action: nil) + delete.isEnabled = deletionEnabled + delete.rx.tap + .subscribe(with: self, onNext: { `self`, _ in + guard self.viewModel.state.sidebarEditingEnabled else { return } + self.viewModel.process(action: .removeSelectedAnnotations) + }) + .disposed(by: disposeBag) + items.append(delete) + deleteBarButton = delete + } else if filterEnabled { + deleteBarButton = nil + + let filterImageName = filterOn ? "line.horizontal.3.decrease.circle.fill" : "line.horizontal.3.decrease.circle" + let filter = UIBarButtonItem(image: UIImage(systemName: filterImageName), style: .plain, target: nil, action: nil) + filter.rx.tap + .subscribe(with: self, onNext: { [weak filter] `self`, _ in + guard let filter else { return } + showFilterPopup(from: filter, viewModel: self.viewModel, coordinatorDelegate: self.coordinatorDelegate) + }) + .disposed(by: disposeBag) + items.insert(filter, at: 0) + } + + let select = UIBarButtonItem(title: (editingEnabled ? L10n.done : L10n.select), style: .plain, target: nil, action: nil) + select.rx.tap + .subscribe(with: self, onNext: { `self`, _ in + self.viewModel.process(action: .setSidebarEditingEnabled(!editingEnabled)) + }) + .disposed(by: disposeBag) + items.append(select) + + toolbar.items = items + + func showFilterPopup(from barButton: UIBarButtonItem, viewModel: ViewModel, coordinatorDelegate: HtmlEpubSidebarCoordinatorDelegate?) { + var colors: Set = [] + var tags: Set = [] + + for (_, annotation) in viewModel.state.annotations { + colors.insert(annotation.color) + for tag in annotation.tags { + tags.insert(tag) + } + } + + let sortedTags = tags.sorted(by: { lTag, rTag -> Bool in + if lTag.color.isEmpty == rTag.color.isEmpty { + return lTag.name.localizedCaseInsensitiveCompare(rTag.name) == .orderedAscending + } + if !lTag.color.isEmpty && rTag.color.isEmpty { + return true + } + return false + }) + var sortedColors: [String] = [] + AnnotationsConfig.allColors.forEach { color in + if colors.contains(color) { + sortedColors.append(color) + } + } + + coordinatorDelegate?.showFilterPopup( + from: barButton, + filter: viewModel.state.annotationFilter, + availableColors: sortedColors, + availableTags: sortedTags, + userInterfaceStyle: viewModel.state.settings.appearance.userInterfaceStyle, + completed: { [weak self] filter in + self?.viewModel.process(action: .changeFilter(filter)) + } + ) + } + } +} + +extension HtmlEpubSidebarViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let key = dataSource.itemIdentifier(for: indexPath) else { return } + if viewModel.state.sidebarEditingEnabled { + viewModel.process(action: .selectAnnotationDuringEditing(key)) + } else { + viewModel.process(action: .selectAnnotationFromSidebar(key)) + } + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard let key = dataSource.itemIdentifier(for: indexPath) else { return } + viewModel.process(action: .deselectAnnotationDuringEditing(key)) + } + + func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { + return tableView.isEditing + } +} diff --git a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift index 31e30023e..321f21ab6 100644 --- a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift +++ b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift @@ -14,7 +14,7 @@ final class AnnotationView: UIView { enum Kind { case cell, popover } - + enum Action { case tags case options(UIButton?) @@ -23,20 +23,20 @@ final class AnnotationView: UIView { case setCommentActive(Bool) case done } - + enum AccessibilityType { case cell case view } - + struct Comment { let attributedString: NSAttributedString? let isActive: Bool } - + private let layout: AnnotationViewLayout let actionPublisher: PublishSubject - + private var header: AnnotationViewHeader! private var topSeparator: UIView! private var highlightContent: AnnotationViewHighlightContent? @@ -52,9 +52,9 @@ final class AnnotationView: UIView { var tagString: String? { return self.tags.textLabel.text } - + // MARK: - Lifecycle - + init(layout: AnnotationViewLayout, commentPlaceholder: String) { self.layout = layout actionPublisher = PublishSubject() @@ -65,30 +65,71 @@ final class AnnotationView: UIView { translatesAutoresizingMaskIntoConstraints = false setupView(commentPlaceholder: commentPlaceholder) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: - Actions - + @discardableResult override func resignFirstResponder() -> Bool { commentTextView.resignFirstResponder() } - + func updatePreview(image: UIImage?) { guard let imageContent, !imageContent.isHidden else { return } imageContent.setup(with: image) } - + private func scrollToBottomIfNeeded() { guard let scrollView, let scrollViewContent, scrollViewContent.frame.height > scrollView.frame.height else { return } let yOffset = scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom scrollView.setContentOffset(CGPoint(x: 0, y: yOffset), animated: true) } - + // MARK: - Setups + + func setup(with annotation: HtmlEpubAnnotation, comment: Comment?, selected: Bool, availableWidth: CGFloat, library: Library, currentUserId: Int) { + let color = UIColor(hex: annotation.color) + let canEdit = library.metadataEditable && selected + let author = library.identifier == .custom(.myLibrary) ? "" : annotation.author + + self.header.setup( + type: annotation.type, + authorName: author, + pageLabel: annotation.pageLabel, + colorHex: annotation.color, + shareMenuProvider: { _ in + return nil + }, + isEditable: canEdit, + showsLock: !library.metadataEditable, + accessibilityType: .cell + ) + self.setupContent( + type: annotation.type, + comment: annotation.comment, + text: annotation.text, + color: color, + canEdit: canEdit, + selected: selected, + availableWidth: availableWidth, + accessibilityType: .cell + ) + self.setup(comment: comment, canEdit: canEdit) + self.setup(tags: annotation.tags, canEdit: canEdit, accessibilityEnabled: selected) + self.setupObserving() + + let commentButtonIsHidden = self.commentTextView.isHidden + let highlightContentIsHidden = self.highlightContent?.isHidden ?? true + let imageContentIsHidden = self.imageContent?.isHidden ?? true + + // Top separator is hidden only if there is only header visible and nothing else + self.topSeparator.isHidden = self.commentTextView.isHidden && commentButtonIsHidden && highlightContentIsHidden && imageContentIsHidden && self.tags.isHidden && self.tagsButton.isHidden + // Bottom separator is visible, when tags are showing (either actual tags or tags button) and there is something visible above them (other than header, either content or comments/comments button) + self.bottomSeparator.isHidden = (self.tags.isHidden && self.tagsButton.isHidden) || (self.commentTextView.isHidden && commentButtonIsHidden && highlightContentIsHidden && imageContentIsHidden) + } /// Setups up annotation view with given annotation and additional data. /// - parameter annotation: Annotation to show in view. diff --git a/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift b/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift index 252727aac..957d7ff18 100644 --- a/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift +++ b/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift @@ -83,6 +83,43 @@ final class AnnotationCell: UITableViewCell { ]) } + func setup( + with annotation: HtmlEpubAnnotation, + comment: AnnotationView.Comment?, + selected: Bool, + availableWidth: CGFloat, + library: Library, + isEditing: Bool, + currentUserId: Int, + state: HtmlEpubReaderState + ) { + if !selected { + annotationView.resignFirstResponder() + } + + key = annotation.key + selectionView.layer.borderWidth = selected ? PDFReaderLayout.cellSelectionLineWidth : 0 + let availableWidth = availableWidth - (PDFReaderLayout.annotationLayout.horizontalInset * 2) + self.annotationView.setup( + with: annotation, + comment: comment, + selected: selected, + availableWidth: availableWidth, + library: library, + currentUserId: currentUserId + ) + + self.setupAccessibility( + isAuthor: annotation.isAuthor, + authorName: annotation.author, + type: annotation.type, + pageLabel: annotation.pageLabel, + text: annotation.text, + comment: annotation.comment, + selected: selected + ) + } + func setup( with annotation: PDFAnnotation, comment: AnnotationView.Comment?, @@ -99,13 +136,13 @@ final class AnnotationCell: UITableViewCell { state: PDFReaderState ) { if !selected { - annotationView.resignFirstResponder() + self.annotationView.resignFirstResponder() } - key = annotation.key - selectionView.layer.borderWidth = selected ? PDFReaderLayout.cellSelectionLineWidth : 0 + self.key = annotation.key + self.selectionView.layer.borderWidth = selected ? PDFReaderLayout.cellSelectionLineWidth : 0 let availableWidth = availableWidth - (PDFReaderLayout.annotationLayout.horizontalInset * 2) - annotationView.setup( + self.annotationView.setup( with: annotation, comment: comment, preview: preview, @@ -120,7 +157,7 @@ final class AnnotationCell: UITableViewCell { state: state ) - setupAccessibility( + self.setupAccessibility( isAuthor: annotation.isAuthor(currentUserId: currentUserId), authorName: annotation.author(displayName: displayName, username: username), type: annotation.type, @@ -133,7 +170,7 @@ final class AnnotationCell: UITableViewCell { private func setupAccessibility(isAuthor: Bool, authorName: String, type: AnnotationType, pageLabel: String, text: String?, comment: String, selected: Bool) { let author = isAuthor ? nil : authorName - var label = accessibilityLabel(for: type, pageLabel: pageLabel, author: author) + var label = self.accessibilityLabel(for: type, pageLabel: pageLabel, author: author) if let text { label += ", " + L10n.Accessibility.Pdf.highlightedText + ": " + text } diff --git a/Zotero/Scenes/General/Models/ReaderSettingsState.swift b/Zotero/Scenes/General/Models/ReaderSettingsState.swift index da627c132..2a7213341 100644 --- a/Zotero/Scenes/General/Models/ReaderSettingsState.swift +++ b/Zotero/Scenes/General/Models/ReaderSettingsState.swift @@ -41,5 +41,15 @@ struct ReaderSettingsState: ViewModelState { self.idleTimerDisabled = settings.idleTimerDisabled } + init(settings: HtmlEpubSettings) { + self.appearance = settings.appearance + self.idleTimerDisabled = settings.idleTimerDisabled + // These don't apply to HTML/Epub, assign random values + self.transition = .curl + self.pageMode = .automatic + self.scrollDirection = .horizontal + self.pageFitting = .adaptive + } + func cleanup() {} }