From 0802b1275a19fc1c9b240dbebf0c1fb2802def2a Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Wed, 13 Mar 2024 11:00:13 +0100 Subject: [PATCH] Text and Underline annotation support (#809) --- Zotero.xcodeproj/project.pbxproj | 36 +- Zotero/Assets/en.lproj/Localizable.strings | 7 + Zotero/Controllers/AnnotationConverter.swift | 89 ++++- ...notationPreviewBoundingBoxCalculator.swift | 7 + .../AnnotationPreviewController.swift | 3 +- .../AttributedTagStringGenerator.swift | 10 +- .../CreatePDFAnnotationsDbRequest.swift | 34 +- .../EditAnnotationFontSizeDbRequest.swift | 37 ++ .../EditAnnotationPathsDbRequest.swift | 12 +- .../EditAnnotationRectsDbRequest.swift | 10 +- .../EditAnnotationRotationDbRequest.swift | 37 ++ .../Requests/ReadAnnotationsDbRequest.swift | 2 + .../Requests/SplitAnnotationsDbRequest.swift | 1 + .../StoreItemsDbResponseRequest.swift | 3 + Zotero/Extensions/Localizable.swift | 14 + Zotero/Extensions/PSPDFKit+Extensions.swift | 2 +- Zotero/Models/API/ItemResponse.swift | 22 +- Zotero/Models/AnnotationTool.swift | 2 + Zotero/Models/AnnotationType.swift | 4 +- Zotero/Models/AnnotationsConfig.swift | 17 +- Zotero/Models/Database/Database.swift | 20 +- Zotero/Models/Database/RItem.swift | 2 + Zotero/Models/Defaults.swift | 9 + Zotero/Models/FieldKeys.swift | 56 ++- Zotero/Models/UpdatableObject.swift | 2 +- .../AnnotationPopoverCoordinator.swift | 12 +- .../Models/AnnotationEditAction.swift | 1 + .../Models/AnnotationEditState.swift | 21 +- .../AnnotationEditActionHandler.swift | 5 + .../Views/AnnotationEditViewController.swift | 51 ++- .../AnnotationPopoverViewController.swift | 3 +- .../Views/AnnotationViewController.swift | 345 ------------------ .../Views/FontSizeCell.swift | 46 +++ .../Views/FontSizePickerViewController.swift | 118 ++++++ .../Views/FontSizeView.swift | 113 ++++++ .../Views/LineWidthView.swift | 6 + .../PDF/AnnotationEditCoordinator.swift | 13 +- .../Detail/PDF/Models/PDFAnnotation.swift | 9 +- .../PDF/Models/PDFDatabaseAnnotation.swift | 91 ++--- .../PDF/Models/PDFDocumentAnnotation.swift | 2 + .../Detail/PDF/Models/PDFReaderAction.swift | 3 +- .../Detail/PDF/Models/PDFReaderState.swift | 7 +- .../PSPDFKItAnnotation+Extensions.swift | 119 ++++++ .../PDF/Models/UnderlineAnnotation.swift | 35 ++ Zotero/Scenes/Detail/PDF/PDFCoordinator.swift | 35 +- .../ViewModels/PDFReaderActionHandler.swift | 179 +++++++-- .../Annotation View/AnnotationView.swift | 31 +- .../AnnotationViewHeader.swift | 27 +- .../AnnotationViewHighlightContent.swift | 4 +- .../Detail/PDF/Views/AnnotationCell.swift | 6 + .../AnnotationToolOptionsViewController.swift | 12 +- .../AnnotationToolbarViewController.swift | 41 ++- .../Views/CustomFreeTextAnnotationView.swift | 172 +++++++++ .../Views/PDFAnnotationsViewController.swift | 39 +- .../PDF/Views/PDFDocumentViewController.swift | 134 +++++-- .../PDF/Views/PDFReaderViewController.swift | 49 +-- 56 files changed, 1514 insertions(+), 653 deletions(-) create mode 100644 Zotero/Controllers/Database/Requests/EditAnnotationFontSizeDbRequest.swift create mode 100644 Zotero/Controllers/Database/Requests/EditAnnotationRotationDbRequest.swift delete mode 100644 Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationViewController.swift create mode 100644 Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeCell.swift create mode 100644 Zotero/Scenes/Detail/Annotation Popover/Views/FontSizePickerViewController.swift create mode 100644 Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeView.swift create mode 100644 Zotero/Scenes/Detail/PDF/Models/PSPDFKItAnnotation+Extensions.swift create mode 100644 Zotero/Scenes/Detail/PDF/Models/UnderlineAnnotation.swift create mode 100644 Zotero/Scenes/Detail/PDF/Views/CustomFreeTextAnnotationView.swift diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index 03ad89db9..29cfc4ed7 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -441,6 +441,7 @@ B324278225C841A600567504 /* WsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B324278125C841A600567504 /* WsResponse.swift */; }; B3242CCD246ABBAF00D8748F /* AnnotationsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3242CCC246ABBAF00D8748F /* AnnotationsConfig.swift */; }; B3243BC82A5EB2740033A7D6 /* HtmlAttributedStringConverterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3243BC72A5EB2740033A7D6 /* HtmlAttributedStringConverterSpec.swift */; }; + B325C40A2A7A8DE0008A2F11 /* CustomFreeTextAnnotationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B325C4092A7A8DE0008A2F11 /* CustomFreeTextAnnotationView.swift */; }; B325DBAC24374F4600EFF0F5 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B325DBAB24374F4600EFF0F5 /* AppCoordinator.swift */; }; B325DBAD24375C7B00EFF0F5 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B305650E23FC051E003304F2 /* UIViewController+Extensions.swift */; }; B325DBAF24375D8D00EFF0F5 /* Conflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = B325DBAE24375D8D00EFF0F5 /* Conflict.swift */; }; @@ -514,6 +515,7 @@ B33E8A4C27B6A7BE00CBC7DE /* SearchableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36181EB24C96B0500B30D56 /* SearchableCollection.swift */; }; B33E8A4D27B6AAE600CBC7DE /* CollectionCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34673CE27B14F0D00444C96 /* CollectionCellContentView.swift */; }; B33E8A4E27B6AAF000CBC7DE /* CollectionCellContentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B37D8E6324DC21D300F526C5 /* CollectionCellContentView.xib */; }; + B33EB2BA2B076657003255DA /* Localizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37080512AA72135006F56B9 /* Localizable.swift */; }; B3401D572567D8F700BB8D6E /* AnnotationPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3401D552567D8F700BB8D6E /* AnnotationPopoverViewController.swift */; }; B3401D5B2567DAAE00BB8D6E /* AnnotationPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3401D5A2567DAAE00BB8D6E /* AnnotationPopoverCoordinator.swift */; }; B3401D612568047D00BB8D6E /* AnnotationViewTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3401D602568047D00BB8D6E /* AnnotationViewTextView.swift */; }; @@ -735,11 +737,14 @@ B36CBD4C25DD397D003C4613 /* ConflictAlertQueueController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36CBD4B25DD397D003C4613 /* ConflictAlertQueueController.swift */; }; B36CBD5A25DD3BDD003C4613 /* ConflictAlertQueueHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36CBD5925DD3BDD003C4613 /* ConflictAlertQueueHandler.swift */; }; B36CBD6225DD4257003C4613 /* ChangedItemsDeletedAlertQueueHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36CBD6125DD4257003C4613 /* ChangedItemsDeletedAlertQueueHandler.swift */; }; + B36E32E22A66850E00C17B81 /* UnderlineAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E32E12A66850E00C17B81 /* UnderlineAnnotation.swift */; }; B36E4F66275123F4001FB99D /* WebDavCreateZoteroDirectoryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E4F65275123F4001FB99D /* WebDavCreateZoteroDirectoryRequest.swift */; }; B36E4F682751242C001FB99D /* WebDavCreateZoteroDirectoryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E4F65275123F4001FB99D /* WebDavCreateZoteroDirectoryRequest.swift */; }; B36E9D4925E51B0E00CD1109 /* AnnotationPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E9D4825E51B0E00CD1109 /* AnnotationPosition.swift */; }; B36EBFF5273162DD00CD788D /* bitcoin.zip in Resources */ = {isa = PBXBuildFile; fileRef = B36EBFF4273162DD00CD788D /* bitcoin.zip */; }; - B37080522AA72135006F56B9 /* Localizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37080512AA72135006F56B9 /* Localizable.swift */; }; + B36FD9AF2A78FB13002D77E8 /* EditAnnotationFontSizeDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36FD9AE2A78FB13002D77E8 /* EditAnnotationFontSizeDbRequest.swift */; }; + B36FD9B12A7924CB002D77E8 /* FontSizePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36FD9B02A7924CB002D77E8 /* FontSizePickerViewController.swift */; }; + B36FD9B32A7929C8002D77E8 /* FontSizeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36FD9B22A7929C8002D77E8 /* FontSizeCell.swift */; }; B37080532AA7216E006F56B9 /* Localizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37080512AA72135006F56B9 /* Localizable.swift */; }; B371494C25D585EA00D6391E /* RestoreDeletionsSyncAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B340868825D574D6000F4446 /* RestoreDeletionsSyncAction.swift */; }; B371494F25D585EF00D6391E /* MarkObjectsAsChangedByUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B340868C25D579C9000F4446 /* MarkObjectsAsChangedByUser.swift */; }; @@ -790,6 +795,7 @@ B37D8E6A24DC2BF300F526C5 /* CollectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37D8E6824DC2BEE00F526C5 /* CollectionRow.swift */; }; B37DD576272AAF500038537D /* FilterAttachmentsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37DD575272AAF500038537D /* FilterAttachmentsDbRequest.swift */; }; B37DD577272AB0930038537D /* FilterAttachmentsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37DD575272AAF500038537D /* FilterAttachmentsDbRequest.swift */; }; + B37EFDBC2A73EEA8008507C5 /* EditAnnotationRotationDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37EFDBB2A73EEA8008507C5 /* EditAnnotationRotationDbRequest.swift */; }; B37F58662672229D00C4089B /* InstallStyleDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37F58652672229D00C4089B /* InstallStyleDbRequest.swift */; }; B37F7ABC23955031008A51B4 /* CreateItemRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37F7ABB23955031008A51B4 /* CreateItemRequest.swift */; }; B3830CDB255451AB00910FE0 /* TagPickerAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3830CD8255451AA00910FE0 /* TagPickerAction.swift */; }; @@ -835,6 +841,7 @@ B3981B76258399AA00F8D15A /* NoteAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3981B75258399AA00F8D15A /* NoteAnnotation.swift */; }; B398A915270C6A4300968EE8 /* WebDavController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398A914270C6A4300968EE8 /* WebDavController.swift */; }; B398A917270C6A5B00968EE8 /* WebDavSessionStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398A916270C6A5B00968EE8 /* WebDavSessionStorage.swift */; }; + B398D6C02A77F9C60049A296 /* FontSizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B398D6BF2A77F9C60049A296 /* FontSizeView.swift */; }; B39AF554290033CD001F400F /* TableOfContentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39AF553290033CD001F400F /* TableOfContentsViewController.swift */; }; B39B18E8223947050019F467 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39B18E7223947050019F467 /* main.swift */; }; B39C9AB7252B589F00462D27 /* Threading+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = B305650423FC051E003304F2 /* Threading+Extras.swift */; }; @@ -901,6 +908,7 @@ B3ADAE4D2833BED300D46271 /* LookupActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ADAE4C2833BED300D46271 /* LookupActionHandler.swift */; }; B3ADAE4F2833BEDC00D46271 /* LookupState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ADAE4E2833BEDC00D46271 /* LookupState.swift */; }; B3ADAE512833BEE300D46271 /* LookupAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ADAE502833BEE300D46271 /* LookupAction.swift */; }; + B3AED7212B98828F000FCD18 /* PSPDFKItAnnotation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3AED7202B98828F000FCD18 /* PSPDFKItAnnotation+Extensions.swift */; }; B3AFB83C2A0CE82C008C2374 /* EmptyDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3AFB83B2A0CE82C008C2374 /* EmptyDecodable.swift */; }; B3AFB83D2A0CF1EE008C2374 /* EmptyDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3AFB83B2A0CE82C008C2374 /* EmptyDecodable.swift */; }; B3B1C5842664E23A00883597 /* ReadItemsForUploadDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B1C5832664E23A00883597 /* ReadItemsForUploadDbRequest.swift */; }; @@ -1505,6 +1513,7 @@ B324278125C841A600567504 /* WsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WsResponse.swift; sourceTree = ""; }; B3242CCC246ABBAF00D8748F /* AnnotationsConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationsConfig.swift; sourceTree = ""; }; B3243BC72A5EB2740033A7D6 /* HtmlAttributedStringConverterSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlAttributedStringConverterSpec.swift; sourceTree = ""; }; + B325C4092A7A8DE0008A2F11 /* CustomFreeTextAnnotationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFreeTextAnnotationView.swift; sourceTree = ""; }; B325DBAB24374F4600EFF0F5 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; B325DBAE24375D8D00EFF0F5 /* Conflict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conflict.swift; sourceTree = ""; }; B325DBB124375DAC00EFF0F5 /* ConflictResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictResolution.swift; sourceTree = ""; }; @@ -1732,9 +1741,13 @@ B36CBD4B25DD397D003C4613 /* ConflictAlertQueueController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictAlertQueueController.swift; sourceTree = ""; }; B36CBD5925DD3BDD003C4613 /* ConflictAlertQueueHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictAlertQueueHandler.swift; sourceTree = ""; }; B36CBD6125DD4257003C4613 /* ChangedItemsDeletedAlertQueueHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangedItemsDeletedAlertQueueHandler.swift; sourceTree = ""; }; + B36E32E12A66850E00C17B81 /* UnderlineAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnderlineAnnotation.swift; sourceTree = ""; }; B36E4F65275123F4001FB99D /* WebDavCreateZoteroDirectoryRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavCreateZoteroDirectoryRequest.swift; sourceTree = ""; }; B36E9D4825E51B0E00CD1109 /* AnnotationPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationPosition.swift; sourceTree = ""; }; B36EBFF4273162DD00CD788D /* bitcoin.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = bitcoin.zip; sourceTree = ""; }; + B36FD9AE2A78FB13002D77E8 /* EditAnnotationFontSizeDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAnnotationFontSizeDbRequest.swift; sourceTree = ""; }; + B36FD9B02A7924CB002D77E8 /* FontSizePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizePickerViewController.swift; sourceTree = ""; }; + B36FD9B22A7929C8002D77E8 /* FontSizeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizeCell.swift; sourceTree = ""; }; B37080512AA72135006F56B9 /* Localizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localizable.swift; sourceTree = ""; }; B372CEDF2486504600B423AE /* GroupVersionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupVersionsRequest.swift; sourceTree = ""; }; B372CEE22486512500B423AE /* GroupRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupRequest.swift; sourceTree = ""; }; @@ -1771,6 +1784,7 @@ B37D8E6324DC21D300F526C5 /* CollectionCellContentView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollectionCellContentView.xib; sourceTree = ""; }; B37D8E6824DC2BEE00F526C5 /* CollectionRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionRow.swift; sourceTree = ""; }; B37DD575272AAF500038537D /* FilterAttachmentsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterAttachmentsDbRequest.swift; sourceTree = ""; }; + B37EFDBB2A73EEA8008507C5 /* EditAnnotationRotationDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAnnotationRotationDbRequest.swift; sourceTree = ""; }; B37F58652672229D00C4089B /* InstallStyleDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallStyleDbRequest.swift; sourceTree = ""; }; B37F7ABB23955031008A51B4 /* CreateItemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateItemRequest.swift; sourceTree = ""; }; B3830CD8255451AA00910FE0 /* TagPickerAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagPickerAction.swift; sourceTree = ""; }; @@ -1809,6 +1823,7 @@ B3981B75258399AA00F8D15A /* NoteAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteAnnotation.swift; sourceTree = ""; }; B398A914270C6A4300968EE8 /* WebDavController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebDavController.swift; sourceTree = ""; }; B398A916270C6A5B00968EE8 /* WebDavSessionStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavSessionStorage.swift; sourceTree = ""; }; + B398D6BF2A77F9C60049A296 /* FontSizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizeView.swift; sourceTree = ""; }; B39AF553290033CD001F400F /* TableOfContentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsViewController.swift; sourceTree = ""; }; B39B18E7223947050019F467 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B39C7BDD251237D600C2CCF1 /* ReadUpdatedItemUpdateParametersSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadUpdatedItemUpdateParametersSpec.swift; sourceTree = ""; }; @@ -1868,6 +1883,7 @@ B3ADAE4C2833BED300D46271 /* LookupActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupActionHandler.swift; sourceTree = ""; }; B3ADAE4E2833BEDC00D46271 /* LookupState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupState.swift; sourceTree = ""; }; B3ADAE502833BEE300D46271 /* LookupAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupAction.swift; sourceTree = ""; }; + B3AED7202B98828F000FCD18 /* PSPDFKItAnnotation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PSPDFKItAnnotation+Extensions.swift"; sourceTree = ""; }; B3AFB83B2A0CE82C008C2374 /* EmptyDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyDecodable.swift; sourceTree = ""; }; B3B1C5832664E23A00883597 /* ReadItemsForUploadDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadItemsForUploadDbRequest.swift; sourceTree = ""; }; B3B1C58526651E2A00883597 /* StorageSettingsActionHandlerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageSettingsActionHandlerSpec.swift; sourceTree = ""; }; @@ -2316,8 +2332,10 @@ B305643823FC051E003304F2 /* DeleteObjectsDbRequest.swift */, B3BF7EE628A51BDC00A5A659 /* DeleteTagFromItemDbRequest.swift */, B310091C272C0126003FC743 /* DeleteWebDavDeletionsDbRequest.swift */, + B36FD9AE2A78FB13002D77E8 /* EditAnnotationFontSizeDbRequest.swift */, B3229FD228C0A09200DAF3B7 /* EditAnnotationPathsDbRequest.swift */, B3229FD028C0A07500DAF3B7 /* EditAnnotationRectsDbRequest.swift */, + B37EFDBB2A73EEA8008507C5 /* EditAnnotationRotationDbRequest.swift */, B305643F23FC051E003304F2 /* EditCollectionDbRequest.swift */, B3863FCB2AD830F0005082F0 /* EditCreatorItemDetailDbRequest.swift */, B32861E528BFACD4007B5A5C /* EditItemFieldsDbRequest.swift */, @@ -2865,6 +2883,7 @@ B340ECA5290FDC9F00EE920D /* AnnotationToolbarViewController.swift */, B38B24B826E7664400BDD1BB /* AnnotationToolOptionsViewController.swift */, B33AB487246D315F00490DDE /* CheckboxButton.swift */, + B325C4092A7A8DE0008A2F11 /* CustomFreeTextAnnotationView.swift */, B3A95DA929194BDE00BCCF11 /* DashedView.swift */, B3756E8E2B835BDC0001CD0A /* IntraDocumentNavigationButtonsHandler.swift */, B31941DB24531F6600BF6296 /* PDFAnnotationsViewController.swift */, @@ -3015,6 +3034,9 @@ B32CDD3C2AEA9BB100EF5054 /* AnnotationPopoverViewController.xib */, B34C40FD25711C990057D5F5 /* ColorPickerCell.swift */, B3764D9C2574F2DE0024274F /* ColorPickerCircleView.swift */, + B36FD9B22A7929C8002D77E8 /* FontSizeCell.swift */, + B36FD9B02A7924CB002D77E8 /* FontSizePickerViewController.swift */, + B398D6BF2A77F9C60049A296 /* FontSizeView.swift */, B398142C257A649D002C755C /* HighlightEditCell.swift */, B398143E257A8EB2002C755C /* HighlightEditCell.xib */, B34A4B7126E65FC200B3E993 /* LineWidthCell.swift */, @@ -3671,9 +3693,11 @@ B310280C2B1E16F300E41554 /* PDFThumbnailsAction.swift */, B373C98E2B1F5431007FD56C /* PDFThumbnailsLayout.swift */, B310280A2B1E16EC00E41554 /* PDFThumbnailsState.swift */, + B3AED7202B98828F000FCD18 /* PSPDFKItAnnotation+Extensions.swift */, B37BF0AA25A5E2AD00AE0268 /* SquareAnnotation.swift */, B3A47C3729015FDD00E7D90D /* TableOfContentsAction.swift */, B3A47C3929015FE800E7D90D /* TableOfContentsState.swift */, + B36E32E12A66850E00C17B81 /* UnderlineAnnotation.swift */, ); path = Models; sourceTree = ""; @@ -4658,6 +4682,7 @@ B305661B23FC051E003304F2 /* SyncVersionsSyncAction.swift in Sources */, B3593F49241A61C700760E20 /* LibrariesAction.swift in Sources */, B3E8FE032714292E00F51458 /* StorageSettingsState.swift in Sources */, + B36FD9B12A7924CB002D77E8 /* FontSizePickerViewController.swift in Sources */, B3DCDF0C240912370039ED0D /* SinglePickerState.swift in Sources */, B30B40652491222000FAAF6D /* AttachmentCreator.swift in Sources */, B3100927272C2441003FC743 /* DeleteWebDavFilesSyncAction.swift in Sources */, @@ -4726,6 +4751,7 @@ B3588C6925FF7A800049D484 /* SetCollectionCollapsedDbRequest.swift in Sources */, B3E8FE3B27142A9900F51458 /* StylePickerState.swift in Sources */, B3E8FE2D271429C300F51458 /* ExportActionHandler.swift in Sources */, + B36FD9AF2A78FB13002D77E8 /* EditAnnotationFontSizeDbRequest.swift in Sources */, B300B3352429222B00C1FE1E /* TranslatorMetadata.swift in Sources */, 61391B832B3C6F83003B314A /* CopyBibliographyActionHandler.swift in Sources */, B3ADAE4A2833B56F00D46271 /* LookupViewController.swift in Sources */, @@ -4759,7 +4785,6 @@ B37BF0AB25A5E2AD00AE0268 /* SquareAnnotation.swift in Sources */, B3EA5A1B2B7251EE00E283D7 /* CitationLocatorContentView.swift in Sources */, B361820024C9A7C000B30D56 /* LoginAction.swift in Sources */, - B37080522AA72135006F56B9 /* Localizable.swift in Sources */, B305660523FC051E003304F2 /* DeletionsRequest.swift in Sources */, B394A0E226A709A900CA307F /* NoteEditorTitleView.swift in Sources */, B305667523FC051F003304F2 /* String+Extensions.swift in Sources */, @@ -4791,6 +4816,7 @@ B3A47C3629015FCE00E7D90D /* TableOfContentsActionHandler.swift in Sources */, B30565BE23FC051E003304F2 /* CreateItemFromDetailDbRequest.swift in Sources */, B3593F77241A76E600760E20 /* CollectionEditActionHandler.swift in Sources */, + B325C40A2A7A8DE0008A2F11 /* CustomFreeTextAnnotationView.swift in Sources */, B3BF7EE728A51BDC00A5A659 /* DeleteTagFromItemDbRequest.swift in Sources */, B305661E23FC051E003304F2 /* DeleteGroupSyncAction.swift in Sources */, B373877328FEA0C7004E5031 /* PDFSidebarViewController.swift in Sources */, @@ -4968,6 +4994,7 @@ B32EE26124A092B700442974 /* RUser.swift in Sources */, B305660A23FC051E003304F2 /* RegisterUploadRequest.swift in Sources */, B31CC57F28646D8E0055C114 /* ManualLookupAction.swift in Sources */, + B398D6C02A77F9C60049A296 /* FontSizeView.swift in Sources */, B3E8FE582714323200F51458 /* SavingSettingsAction.swift in Sources */, B305662B23FC051F003304F2 /* SyncProgressHandler.swift in Sources */, B3F09AE629CAFF860084E4D8 /* TagFilterActionHandler.swift in Sources */, @@ -5020,6 +5047,7 @@ B32DE61F26320271000287EC /* PopoverNavigationViewController.swift in Sources */, B3CAE1182B0E38BA0000F8CA /* EmojiExtractor.swift in Sources */, B3DEAAA728AD01CA00F72D90 /* URLSession+Extensions.swift in Sources */, + B36FD9B32A7929C8002D77E8 /* FontSizeCell.swift in Sources */, B357A28C285B73BD00E73CA1 /* ScannerState.swift in Sources */, B3E8FE5A2714325200F51458 /* SavingSettingsActionHandler.swift in Sources */, B30566B623FC051F003304F2 /* DeletionsResponse.swift in Sources */, @@ -5031,6 +5059,7 @@ B305660823FC051E003304F2 /* KeyRequest.swift in Sources */, B32A273C254841B80081E061 /* CreatorEditViewController.swift in Sources */, B31D4A722767840800E22DCC /* BackgroundTaskController.swift in Sources */, + B37EFDBC2A73EEA8008507C5 /* EditAnnotationRotationDbRequest.swift in Sources */, B3593AD724B601AB00CA0B57 /* PDFSearchCell.swift in Sources */, B30566AC23FC051F003304F2 /* Predicates.swift in Sources */, B30566A023FC051F003304F2 /* KeyedResponse.swift in Sources */, @@ -5118,6 +5147,7 @@ B300B33324291C8D00C1FE1E /* RTranslatorMetadata.swift in Sources */, B310FA4529E5765800FA2F15 /* AddTagsToItemDbRequest.swift in Sources */, B39D42DF29C0CE7D0035CDA9 /* TagsFlowLayout.swift in Sources */, + B33EB2BA2B076657003255DA /* Localizable.swift in Sources */, B30BDDE628366F1B007034E8 /* CreateTranslatedItemsDbRequest.swift in Sources */, B31A5E51286308960026589F /* LookupIdentifierCell.swift in Sources */, B319D6B8265CE0C200E52132 /* ReadAllDownloadedAndForUploadItemsDbRequest.swift in Sources */, @@ -5133,6 +5163,7 @@ B395EFE325494B1B00CEBD9F /* CreatorEditAction.swift in Sources */, B3C8DD8227B502960084E1AD /* CollectionTreeBuilder.swift in Sources */, B30565FB23FC051E003304F2 /* Formatter.swift in Sources */, + B36E32E22A66850E00C17B81 /* UnderlineAnnotation.swift in Sources */, B32861E628BFACD4007B5A5C /* EditItemFieldsDbRequest.swift in Sources */, B3329A592B73A68900F17636 /* CitationPreviewCell.swift in Sources */, B36C07DE26FB264800C855A9 /* UITableView+Extensions.swift in Sources */, @@ -5219,6 +5250,7 @@ B30BDDE42836642D007034E8 /* FilenameFormatter.swift in Sources */, B3E381D325F2D78A00F046CE /* ApiLogger.swift in Sources */, B3E8FE192714297200F51458 /* CiteAction.swift in Sources */, + B3AED7212B98828F000FCD18 /* PSPDFKItAnnotation+Extensions.swift in Sources */, B3A2AEDC2656511D004BF3A4 /* StylesRequest.swift in Sources */, B3F3D62C255EBE3500F310C2 /* AnnotationViewHeader.swift in Sources */, B3E8FE34271429C300F51458 /* ExportLocalePickerView.swift in Sources */, diff --git a/Zotero/Assets/en.lproj/Localizable.strings b/Zotero/Assets/en.lproj/Localizable.strings index 9d175dd24..a3949337e 100644 --- a/Zotero/Assets/en.lproj/Localizable.strings +++ b/Zotero/Assets/en.lproj/Localizable.strings @@ -241,6 +241,8 @@ "pdf.annotation_toolbar.note" = "Note"; "pdf.annotation_toolbar.image" = "Image"; "pdf.annotation_toolbar.ink" = "Ink"; +"pdf.annotation_toolbar.underline" = "Underline"; +"pdf.annotation_toolbar.text" = "Text"; "pdf.locked.locked" = "Locked"; "pdf.locked.enter_password" = "Please enter the password to open this PDF."; "pdf.locked.failed" = "Incorrect password. Please try again."; @@ -267,6 +269,7 @@ "pdf.export.export_annotated" = "Export Annotated PDF"; "pdf.sidebar.no_annotations" = "No Annotations"; "pdf.sidebar.no_outline" = "No Outline"; +"pdf.delete_annotation" = "Do you really want to delete annotation?"; "settings.title" = "Settings"; "settings.logout" = "Sign Out"; @@ -520,11 +523,15 @@ "accessibility.pdf.note_annotation_tool" = "Create note annotation"; "accessibility.pdf.image_annotation_tool" = "Create image annotation"; "accessibility.pdf.ink_annotation_tool" = "Create ink annotation"; +"accessibility.pdf.underline_annotation_tool" = "Create underline annotation"; +"accessibility.pdf.text_annotation_tool" = "Create text annotation"; "accessibility.pdf.eraser_annotation_tool" = "Eraser"; "accessibility.pdf.highlight_annotation" = "Highlight annotation"; "accessibility.pdf.note_annotation" = "Note annotation"; "accessibility.pdf.image_annotation" = "Image annotation"; "accessibility.pdf.ink_annotation" = "Ink annotation"; +"accessibility.pdf.underline_annotation" = "Underline annotation"; +"accessibility.pdf.text_annotation" = "Text annotation"; "accessibility.pdf.edit_annotation" = "Edit annotation"; "accessibility.pdf.share_annotation" = "Share annotation"; "accessibility.pdf.share_annotation_image" = "Share annotation image"; diff --git a/Zotero/Controllers/AnnotationConverter.swift b/Zotero/Controllers/AnnotationConverter.swift index 405666474..e292ed9a6 100644 --- a/Zotero/Controllers/AnnotationConverter.swift +++ b/Zotero/Controllers/AnnotationConverter.swift @@ -81,34 +81,41 @@ struct AnnotationConverter { let type: AnnotationType let rects: [CGRect] - let text: String? + var text: String? let paths: [[CGPoint]] - let lineWidth: CGFloat? + var lineWidth: CGFloat? + var fontSize: UInt? + var rotation: UInt? if let annotation = annotation as? PSPDFKit.NoteAnnotation { type = .note rects = self.rects(fromNoteAnnotation: annotation) - text = nil paths = [] - lineWidth = nil } else if let annotation = annotation as? PSPDFKit.HighlightAnnotation { type = .highlight - rects = self.rects(fromHighlightAnnotation: annotation) + rects = self.rects(fromHighlightAndUnderlineAnnotation: annotation) text = TextConverter.convertTextForAnnotation(from: annotation.markedUpString) paths = [] - lineWidth = nil } else if let annotation = annotation as? PSPDFKit.SquareAnnotation { type = .image rects = self.rects(fromSquareAnnotation: annotation) - text = nil paths = [] - lineWidth = nil } else if let annotation = annotation as? PSPDFKit.InkAnnotation { type = .ink rects = [] - text = nil paths = self.paths(from: annotation) lineWidth = annotation.lineWidth + } else if let annotation = annotation as? PSPDFKit.UnderlineAnnotation { + type = .underline + rects = self.rects(fromHighlightAndUnderlineAnnotation: annotation) + text = TextConverter.convertTextForAnnotation(from: annotation.markedUpString) + paths = [] + } else if let annotation = annotation as? PSPDFKit.FreeTextAnnotation { + type = .freeText + fontSize = UInt(annotation.fontSize) + rotation = annotation.rotation + paths = [] + rects = self.rects(fromTextAnnotation: annotation) } else { return nil } @@ -126,6 +133,8 @@ struct AnnotationConverter { color: color, comment: comment, text: text, + fontSize: fontSize, + rotation: rotation, sortIndex: sortIndex, dateModified: date ) @@ -143,12 +152,15 @@ struct AnnotationConverter { if let annotation = annotation as? PSPDFKit.NoteAnnotation { return self.rects(fromNoteAnnotation: annotation) } - if let annotation = annotation as? PSPDFKit.HighlightAnnotation { - return self.rects(fromHighlightAnnotation: annotation) + if annotation is PSPDFKit.HighlightAnnotation || annotation is PSPDFKit.UnderlineAnnotation { + return self.rects(fromHighlightAndUnderlineAnnotation: annotation) } if let annotation = annotation as? PSPDFKit.SquareAnnotation { return self.rects(fromSquareAnnotation: annotation) } + if let annotation = annotation as? PSPDFKit.FreeTextAnnotation { + return self.rects(fromTextAnnotation: annotation) + } return nil } @@ -156,7 +168,7 @@ struct AnnotationConverter { return [CGRect(origin: annotation.boundingBox.origin.rounded(to: 3), size: AnnotationsConfig.noteAnnotationSize)] } - private static func rects(fromHighlightAnnotation annotation: PSPDFKit.HighlightAnnotation) -> [CGRect] { + private static func rects(fromHighlightAndUnderlineAnnotation annotation: PSPDFKit.Annotation) -> [CGRect] { return (annotation.rects ?? [annotation.boundingBox]).map({ $0.rounded(to: 3) }) } @@ -164,6 +176,15 @@ struct AnnotationConverter { return [annotation.boundingBox.rounded(to: 3)] } + private static func rects(fromTextAnnotation annotation: PSPDFKit.FreeTextAnnotation) -> [CGRect] { + guard annotation.rotation > 0 else { return [annotation.boundingBox] } + let originalRotation = annotation.rotation + annotation.setRotation(0, updateBoundingBox: true) + let boundingBox = annotation.boundingBox.rounded(to: 3) + annotation.setRotation(originalRotation, updateBoundingBox: true) + return [boundingBox] + } + private static func createName(from displayName: String, username: String) -> String { if !displayName.isEmpty { return displayName @@ -189,9 +210,10 @@ struct AnnotationConverter { username: String, boundingBoxConverter: AnnotationBoundingBoxConverter ) -> [PSPDFKit.Annotation] { - return items.map({ item in + return items.compactMap({ item in + guard let annotation = PDFDatabaseAnnotation(item: item) else { return nil } return self.annotation( - from: PDFDatabaseAnnotation(item: item), + from: annotation, type: type, interfaceStyle: interfaceStyle, currentUserId: currentUserId, @@ -232,6 +254,12 @@ struct AnnotationConverter { case .ink: annotation = self.inkAnnotation(from: zoteroAnnotation, type: type, color: color, boundingBoxConverter: boundingBoxConverter) + + case .underline: + annotation = self.underlineAnnotation(from: zoteroAnnotation, type: type, color: color, alpha: alpha, boundingBoxConverter: boundingBoxConverter) + + case .freeText: + annotation = self.freeTextAnnotation(from: zoteroAnnotation, color: color, boundingBoxConverter: boundingBoxConverter) } switch type { @@ -332,6 +360,39 @@ struct AnnotationConverter { ink.lineWidth = annotation.lineWidth ?? 1 return ink } + + private static func underlineAnnotation( + from annotation: PDFAnnotation, + type: Kind, + color: UIColor, + alpha: CGFloat, + boundingBoxConverter: AnnotationBoundingBoxConverter + ) -> PSPDFKit.UnderlineAnnotation { + let underline: PSPDFKit.UnderlineAnnotation + switch type { + case .export: + underline = PSPDFKit.UnderlineAnnotation() + + case .zotero: + underline = UnderlineAnnotation() + } + + underline.boundingBox = annotation.boundingBox(boundingBoxConverter: boundingBoxConverter).rounded(to: 3) + underline.rects = annotation.rects(boundingBoxConverter: boundingBoxConverter).map({ $0.rounded(to: 3) }) + underline.color = color + underline.alpha = alpha + + return underline + } + + private static func freeTextAnnotation(from annotation: PDFAnnotation, color: UIColor, boundingBoxConverter: AnnotationBoundingBoxConverter) -> PSPDFKit.FreeTextAnnotation { + let text = PSPDFKit.FreeTextAnnotation(contents: annotation.comment) + text.color = color + text.fontSize = CGFloat(annotation.fontSize ?? 0) + text.setBoundingBox(annotation.boundingBox(boundingBoxConverter: boundingBoxConverter).rounded(to: 3), transformSize: true) + text.setRotation(annotation.rotation ?? 0, updateBoundingBox: true) + return text + } } extension RItem { diff --git a/Zotero/Controllers/AnnotationPreviewBoundingBoxCalculator.swift b/Zotero/Controllers/AnnotationPreviewBoundingBoxCalculator.swift index aff5f5c93..8753edd78 100644 --- a/Zotero/Controllers/AnnotationPreviewBoundingBoxCalculator.swift +++ b/Zotero/Controllers/AnnotationPreviewBoundingBoxCalculator.swift @@ -38,4 +38,11 @@ struct AnnotationPreviewBoundingBoxCalculator { static func imagePreviewRect(from boundingBox: CGRect, lineWidth: CGFloat) -> CGRect { return boundingBox.insetBy(dx: (lineWidth + 1), dy: (lineWidth + 1)).rounded(to: 3) } + + static func freeTextPreviewRect(from boundingBox: CGRect, rotation: UInt) -> CGRect { + let x = boundingBox.midX + let y = boundingBox.midY + let transform = CGAffineTransform(translationX: x, y: y).rotated(by: CGFloat(rotation) * .pi / 180).translatedBy(x: -x, y: -y) + return boundingBox.applying(transform) + } } diff --git a/Zotero/Controllers/AnnotationPreviewController.swift b/Zotero/Controllers/AnnotationPreviewController.swift index 6ce74bc93..45b3c16e4 100644 --- a/Zotero/Controllers/AnnotationPreviewController.swift +++ b/Zotero/Controllers/AnnotationPreviewController.swift @@ -102,6 +102,7 @@ extension AnnotationPreviewController { // Cache and report original color let rect = annotation.previewBoundingBox + let includeAnnotation = annotation is PSPDFKit.InkAnnotation || annotation is PSPDFKit.FreeTextAnnotation self.enqueue( key: annotation.previewId, parentKey: parentKey, @@ -111,7 +112,7 @@ extension AnnotationPreviewController { rect: rect, imageSize: previewSize, imageScale: 0.0, - includeAnnotation: (annotation is PSPDFKit.InkAnnotation), + includeAnnotation: includeAnnotation, invertColors: false, isDark: isDark, type: .cachedAndReported diff --git a/Zotero/Controllers/AttributedTagStringGenerator.swift b/Zotero/Controllers/AttributedTagStringGenerator.swift index c9336cae6..4b47a0cc0 100644 --- a/Zotero/Controllers/AttributedTagStringGenerator.swift +++ b/Zotero/Controllers/AttributedTagStringGenerator.swift @@ -50,8 +50,16 @@ struct AttributedTagStringGenerator { } static func attributedString(from tags: [Tag], limit: Int? = nil) -> NSMutableAttributedString { + let sorted = tags.sorted { lTag, rTag in + if !rTag.color.isEmpty && lTag.color.isEmpty { + return false + } else if rTag.color.isEmpty && !lTag.color.isEmpty { + return true + } + return lTag.name.localizedCaseInsensitiveCompare(rTag.name) == .orderedAscending + } let wholeString = NSMutableAttributedString() - for (index, tag) in tags.enumerated() { + for (index, tag) in sorted.enumerated() { if let limit = limit, index == limit { break } diff --git a/Zotero/Controllers/Database/Requests/CreatePDFAnnotationsDbRequest.swift b/Zotero/Controllers/Database/Requests/CreatePDFAnnotationsDbRequest.swift index e78f22fc8..9d3870eca 100644 --- a/Zotero/Controllers/Database/Requests/CreatePDFAnnotationsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/CreatePDFAnnotationsDbRequest.swift @@ -22,17 +22,17 @@ struct CreatePDFAnnotationsDbRequest: DbRequest { 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 } + guard let parent = database.objects(RItem.self).filter(.key(attachmentKey, in: libraryId)).first else { return } - for annotation in self.annotations { - self.create(annotation: annotation, parent: parent, in: database) + for annotation in annotations { + create(annotation: annotation, parent: parent, in: database) } } private func create(annotation: PDFDocumentAnnotation, parent: RItem, in database: Realm) { let item: RItem - if let _item = database.objects(RItem.self).filter(.key(annotation.key, in: self.libraryId)).first { + if let _item = database.objects(RItem.self).filter(.key(annotation.key, in: libraryId)).first { if !_item.deleted { // If item exists and is not deleted locally, we can ignore this request return @@ -46,12 +46,13 @@ struct CreatePDFAnnotationsDbRequest: DbRequest { item = RItem() item.key = annotation.key item.rawType = ItemTypes.annotation - item.localizedType = self.schemaController.localized(itemType: ItemTypes.annotation) ?? "" - item.libraryId = self.libraryId + item.localizedType = schemaController.localized(itemType: ItemTypes.annotation) ?? "" + item.libraryId = libraryId item.dateAdded = annotation.dateModified database.add(item) } + item.annotationType = annotation.type.rawValue item.syncState = .synced item.changeType = .user item.htmlFreeContent = annotation.comment.isEmpty ? nil : annotation.comment.strippedRichTextTags @@ -89,8 +90,10 @@ struct CreatePDFAnnotationsDbRequest: DbRequest { case FieldKeys.Item.Annotation.Position.pageIndex where field.baseKey == FieldKeys.Item.Annotation.position: rField.value = "\(annotation.page)" + case FieldKeys.Item.Annotation.Position.lineWidth where field.baseKey == FieldKeys.Item.Annotation.position: rField.value = annotation.lineWidth.flatMap({ "\(Decimal($0).rounded(to: 3))" }) ?? "" + case FieldKeys.Item.Annotation.pageLabel: rField.value = annotation.pageLabel @@ -100,6 +103,13 @@ struct CreatePDFAnnotationsDbRequest: DbRequest { case FieldKeys.Item.Annotation.text: rField.value = annotation.text ?? "" + + case FieldKeys.Item.Annotation.Position.rotation where field.baseKey == FieldKeys.Item.Annotation.position: + rField.value = "\(annotation.rotation ?? 0)" + + case FieldKeys.Item.Annotation.Position.fontSize where field.baseKey == FieldKeys.Item.Annotation.position: + rField.value = "\(annotation.fontSize ?? 0)" + default: break } @@ -108,12 +118,12 @@ struct CreatePDFAnnotationsDbRequest: DbRequest { } private func add(rects: [CGRect], to item: RItem, changes: inout RItemChanges, database: Realm) { - guard !rects.isEmpty else { return } + guard !rects.isEmpty, let annotation = PDFDatabaseAnnotation(item: item) else { return } - let page = UInt(PDFDatabaseAnnotation(item: item).page) + let page = UInt(annotation.page) for rect in rects { - let dbRect = self.boundingBoxConverter.convertToDb(rect: rect, page: page) ?? rect + let dbRect = boundingBoxConverter.convertToDb(rect: rect, page: page) ?? rect let rRect = RRect() rRect.minX = Double(dbRect.minX) @@ -126,16 +136,16 @@ struct CreatePDFAnnotationsDbRequest: DbRequest { } private func add(paths: [[CGPoint]], to item: RItem, changes: inout RItemChanges, database: Realm) { - guard !paths.isEmpty else { return } + guard !paths.isEmpty, let annotation = PDFDatabaseAnnotation(item: item) else { return } - let page = UInt(PDFDatabaseAnnotation(item: item).page) + let page = UInt(annotation.page) for (idx, path) in paths.enumerated() { let rPath = RPath() rPath.sortIndex = idx for (idy, point) in path.enumerated() { - let dbPoint = self.boundingBoxConverter.convertToDb(point: point, page: page) ?? point + let dbPoint = boundingBoxConverter.convertToDb(point: point, page: page) ?? point let rXCoordinate = RPathCoordinate() rXCoordinate.value = Double(dbPoint.x) diff --git a/Zotero/Controllers/Database/Requests/EditAnnotationFontSizeDbRequest.swift b/Zotero/Controllers/Database/Requests/EditAnnotationFontSizeDbRequest.swift new file mode 100644 index 000000000..8d3192049 --- /dev/null +++ b/Zotero/Controllers/Database/Requests/EditAnnotationFontSizeDbRequest.swift @@ -0,0 +1,37 @@ +// +// EditAnnotationFontSizeDbRequest.swift +// Zotero +// +// Created by Michal Rentka on 01.08.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +import RealmSwift + +struct EditAnnotationFontSizeDbRequest: DbRequest { + let key: String + let libraryId: LibraryIdentifier + let size: UInt + + var needsWrite: Bool { return true } + + func process(in database: Realm) throws { + guard let item = database.objects(RItem.self).filter(.key(self.key, in: self.libraryId)).first else { return } + + let field: RItemField + if let _field = item.fields.filter(.key(FieldKeys.Item.Annotation.Position.fontSize)).first { + field = _field + } else { + field = RItemField() + field.key = FieldKeys.Item.Annotation.Position.fontSize + field.baseKey = FieldKeys.Item.Annotation.position + item.fields.append(field) + } + + field.value = "\(self.size)" + item.changeType = .user + item.changes.append(RObjectChange.create(changes: RItemChanges.fields)) + } +} diff --git a/Zotero/Controllers/Database/Requests/EditAnnotationPathsDbRequest.swift b/Zotero/Controllers/Database/Requests/EditAnnotationPathsDbRequest.swift index e1993b17d..f17bc9ecc 100644 --- a/Zotero/Controllers/Database/Requests/EditAnnotationPathsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/EditAnnotationPathsDbRequest.swift @@ -19,13 +19,13 @@ struct EditAnnotationPathsDbRequest: DbRequest { var needsWrite: Bool { return true } func process(in database: Realm) throws { - guard let item = database.objects(RItem.self).filter(.key(self.key, in: self.libraryId)).first else { return } - let page = UInt(PDFDatabaseAnnotation(item: item).page) - let dbPaths = self.paths.map { path in - return path.map({ self.boundingBoxConverter.convertToDb(point: $0, page: page) ?? $0 }) + guard let item = database.objects(RItem.self).filter(.key(key, in: libraryId)).first, let annotation = PDFDatabaseAnnotation(item: item) else { return } + let page = UInt(annotation.page) + let dbPaths = paths.map { path in + return path.map({ boundingBoxConverter.convertToDb(point: $0, page: page) ?? $0 }) } - guard self.paths(dbPaths, differFrom: item.paths) else { return } - self.sync(paths: dbPaths, in: item, database: database) + guard paths(dbPaths, differFrom: item.paths) else { return } + sync(paths: dbPaths, in: item, database: database) } private func sync(paths: [[CGPoint]], in item: RItem, database: Realm) { diff --git a/Zotero/Controllers/Database/Requests/EditAnnotationRectsDbRequest.swift b/Zotero/Controllers/Database/Requests/EditAnnotationRectsDbRequest.swift index c5883f988..c9b6fd037 100644 --- a/Zotero/Controllers/Database/Requests/EditAnnotationRectsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/EditAnnotationRectsDbRequest.swift @@ -19,11 +19,11 @@ struct EditAnnotationRectsDbRequest: DbRequest { var needsWrite: Bool { return true } func process(in database: Realm) throws { - guard let item = database.objects(RItem.self).filter(.key(self.key, in: self.libraryId)).first else { return } - let page = UInt(PDFDatabaseAnnotation(item: item).page) - let dbRects = self.rects.map({ self.boundingBoxConverter.convertToDb(rect: $0, page: page) ?? $0 }) - guard self.rects(dbRects, differFrom: item.rects) else { return } - self.sync(rects: dbRects, in: item, database: database) + guard let item = database.objects(RItem.self).filter(.key(key, in: libraryId)).first, let annotation = PDFDatabaseAnnotation(item: item) else { return } + let page = UInt(annotation.page) + let dbRects = rects.map({ boundingBoxConverter.convertToDb(rect: $0, page: page) ?? $0 }) + guard rects(dbRects, differFrom: item.rects) else { return } + sync(rects: dbRects, in: item, database: database) } private func sync(rects: [CGRect], in item: RItem, database: Realm) { diff --git a/Zotero/Controllers/Database/Requests/EditAnnotationRotationDbRequest.swift b/Zotero/Controllers/Database/Requests/EditAnnotationRotationDbRequest.swift new file mode 100644 index 000000000..a51f80d71 --- /dev/null +++ b/Zotero/Controllers/Database/Requests/EditAnnotationRotationDbRequest.swift @@ -0,0 +1,37 @@ +// +// EditAnnotationRotationDbRequest.swift +// Zotero +// +// Created by Michal Rentka on 28.07.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +import RealmSwift + +struct EditAnnotationRotationDbRequest: DbRequest { + let key: String + let libraryId: LibraryIdentifier + let rotation: UInt + + var needsWrite: Bool { return true } + + func process(in database: Realm) throws { + guard let item = database.objects(RItem.self).filter(.key(self.key, in: self.libraryId)).first else { return } + + let field: RItemField + if let _field = item.fields.filter(.key(FieldKeys.Item.Annotation.Position.rotation)).first { + field = _field + } else { + field = RItemField() + field.key = FieldKeys.Item.Annotation.Position.rotation + field.baseKey = FieldKeys.Item.Annotation.position + item.fields.append(field) + } + + field.value = "\(self.rotation)" + item.changeType = .user + item.changes.append(RObjectChange.create(changes: RItemChanges.fields)) + } +} diff --git a/Zotero/Controllers/Database/Requests/ReadAnnotationsDbRequest.swift b/Zotero/Controllers/Database/Requests/ReadAnnotationsDbRequest.swift index 66169086b..51eadb2ec 100644 --- a/Zotero/Controllers/Database/Requests/ReadAnnotationsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/ReadAnnotationsDbRequest.swift @@ -19,9 +19,11 @@ struct ReadAnnotationsDbRequest: DbResponseRequest { var needsWrite: Bool { return false } func process(in database: Realm) throws -> Results { + let supportedTypes = AnnotationType.allCases.filter({ AnnotationsConfig.supported.contains($0.kind) }).map({ $0.rawValue }) return database.objects(RItem.self).filter(.parent(self.attachmentKey, in: self.libraryId)) .filter(.items(type: ItemTypes.annotation, notSyncState: .dirty)) .filter(.deleted(false)) + .filter("annotationType in %@", supportedTypes) .sorted(byKeyPath: "annotationSortIndex", ascending: true) } } diff --git a/Zotero/Controllers/Database/Requests/SplitAnnotationsDbRequest.swift b/Zotero/Controllers/Database/Requests/SplitAnnotationsDbRequest.swift index 4467c7ba5..d168d2457 100644 --- a/Zotero/Controllers/Database/Requests/SplitAnnotationsDbRequest.swift +++ b/Zotero/Controllers/Database/Requests/SplitAnnotationsDbRequest.swift @@ -112,6 +112,7 @@ struct SplitAnnotationsDbRequest: DbRequest { let new = RItem() new.key = KeyGenerator.newKey new.rawType = item.rawType + new.annotationType = item.annotationType new.localizedType = item.localizedType new.dateAdded = item.dateAdded new.dateModified = item.dateModified diff --git a/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift b/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift index 00d4de7fa..af0cbebe0 100644 --- a/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift +++ b/Zotero/Controllers/Database/Requests/StoreItemsDbResponseRequest.swift @@ -223,6 +223,9 @@ struct StoreItemDbRequest: DbResponseRequest { case (FieldKeys.Item.Annotation.comment, _) where item.rawType == ItemTypes.annotation: item.htmlFreeContent = value.isEmpty ? nil : value.strippedRichTextTags + case (FieldKeys.Item.Annotation.type, _) where item.rawType == ItemTypes.annotation: + item.annotationType = value + case (FieldKeys.Item.date, _): date = value diff --git a/Zotero/Extensions/Localizable.swift b/Zotero/Extensions/Localizable.swift index de49962e1..2549f25e1 100644 --- a/Zotero/Extensions/Localizable.swift +++ b/Zotero/Extensions/Localizable.swift @@ -246,8 +246,16 @@ internal enum L10n { internal static let tags = L10n.tr("Localizable", "accessibility.pdf.tags", fallback: "Tags") /// Double tap to edit tags internal static let tagsHint = L10n.tr("Localizable", "accessibility.pdf.tags_hint", fallback: "Double tap to edit tags") + /// Text annotation + internal static let textAnnotation = L10n.tr("Localizable", "accessibility.pdf.text_annotation", fallback: "Text annotation") + /// Create text annotation + internal static let textAnnotationTool = L10n.tr("Localizable", "accessibility.pdf.text_annotation_tool", fallback: "Create text annotation") /// Toggle annotation toolbar internal static let toggleAnnotationToolbar = L10n.tr("Localizable", "accessibility.pdf.toggle_annotation_toolbar", fallback: "Toggle annotation toolbar") + /// Underline annotation + internal static let underlineAnnotation = L10n.tr("Localizable", "accessibility.pdf.underline_annotation", fallback: "Underline annotation") + /// Create underline annotation + internal static let underlineAnnotationTool = L10n.tr("Localizable", "accessibility.pdf.underline_annotation_tool", fallback: "Create underline annotation") /// Undo internal static let undo = L10n.tr("Localizable", "accessibility.pdf.undo", fallback: "Undo") } @@ -889,6 +897,8 @@ internal enum L10n { internal static let sync = L10n.tr("Localizable", "onboarding.sync", fallback: "Synchronize and collaborate across devices, keeping your reading and notes seamlessly up to date.") } internal enum Pdf { + /// Do you really want to delete annotation? + internal static let deleteAnnotation = L10n.tr("Localizable", "pdf.delete_annotation", fallback: "Do you really want to delete annotation?") /// This document has been deleted. Do you want to restore it? internal static let deletedMessage = L10n.tr("Localizable", "pdf.deleted_message", fallback: "This document has been deleted. Do you want to restore it?") /// Deleted @@ -936,6 +946,10 @@ internal enum L10n { internal static let ink = L10n.tr("Localizable", "pdf.annotation_toolbar.ink", fallback: "Ink") /// Note internal static let note = L10n.tr("Localizable", "pdf.annotation_toolbar.note", fallback: "Note") + /// Text + internal static let text = L10n.tr("Localizable", "pdf.annotation_toolbar.text", fallback: "Text") + /// Underline + internal static let underline = L10n.tr("Localizable", "pdf.annotation_toolbar.underline", fallback: "Underline") } internal enum AnnotationsSidebar { /// Add comment diff --git a/Zotero/Extensions/PSPDFKit+Extensions.swift b/Zotero/Extensions/PSPDFKit+Extensions.swift index 1370e56d0..67dfb2b92 100644 --- a/Zotero/Extensions/PSPDFKit+Extensions.swift +++ b/Zotero/Extensions/PSPDFKit+Extensions.swift @@ -43,7 +43,7 @@ extension PSPDFKit.Annotation { } var shouldRenderPreview: Bool { - return (self is PSPDFKit.SquareAnnotation) || (self is PSPDFKit.InkAnnotation) + return (self is PSPDFKit.SquareAnnotation) || (self is PSPDFKit.InkAnnotation) || (self is PSPDFKit.FreeTextAnnotation) } var previewId: String { diff --git a/Zotero/Models/API/ItemResponse.swift b/Zotero/Models/API/ItemResponse.swift index 7012da33e..c120142e0 100644 --- a/Zotero/Models/API/ItemResponse.swift +++ b/Zotero/Models/API/ItemResponse.swift @@ -373,28 +373,16 @@ struct ItemResponse { for object in json { switch object.key { - case FieldKeys.Item.Annotation.Position.pageIndex: - if (object.value as? Int) == nil { - throw SchemaError.invalidValue(value: "\(object.value)", field: FieldKeys.Item.Annotation.Position.pageIndex, key: key) - } - - case FieldKeys.Item.Annotation.Position.lineWidth: - if (object.value as? Double) == nil { - throw SchemaError.invalidValue(value: "\(object.value)", field: FieldKeys.Item.Annotation.Position.lineWidth, key: key) - } - case FieldKeys.Item.Annotation.Position.paths: - guard let parsedPaths = object.value as? [[Double]], !parsedPaths.isEmpty && !parsedPaths.contains(where: { $0.count % 2 != 0 }) else { - throw SchemaError.invalidValue(value: "\(object.value)", field: FieldKeys.Item.Annotation.Position.paths, key: key) + if let parsedPaths = object.value as? [[Double]], !parsedPaths.isEmpty && !parsedPaths.contains(where: { $0.count % 2 != 0 }) { + paths = parsedPaths } - paths = parsedPaths continue case FieldKeys.Item.Annotation.Position.rects: - guard let parsedRects = object.value as? [[Double]], !parsedRects.isEmpty && !parsedRects.contains(where: { $0.count != 4 }) else { - throw SchemaError.invalidValue(value: "\(object.value)", field: FieldKeys.Item.Annotation.Position.rects, key: key) + if let parsedRects = object.value as? [[Double]], !parsedRects.isEmpty && !parsedRects.contains(where: { $0.count != 4 }) { + rects = parsedRects } - rects = parsedRects continue default: break @@ -424,7 +412,7 @@ struct ItemResponse { private static func validate(fields: [KeyBaseKeyPair: String], itemType: String, key: String, hasPaths: Bool, hasRects: Bool) throws { switch itemType { case ItemTypes.annotation: - // `position` values are validated in `parsePositionFields(from:key:fields:)` where we have access to their original value, instead of just `String`. + // `position` values are not validated at this point. They depend on content type of parent (attachment) item, which is unknown here, so they are validated when this item is being opened. guard let rawType = fields[KeyBaseKeyPair(key: FieldKeys.Item.Annotation.type, baseKey: nil)] else { throw SchemaError.missingField(key: key, field: FieldKeys.Item.Annotation.type, itemType: itemType) } diff --git a/Zotero/Models/AnnotationTool.swift b/Zotero/Models/AnnotationTool.swift index 1bbf5f4c7..beff18605 100644 --- a/Zotero/Models/AnnotationTool.swift +++ b/Zotero/Models/AnnotationTool.swift @@ -14,4 +14,6 @@ enum AnnotationTool { case note case highlight case eraser + case underline + case freeText } diff --git a/Zotero/Models/AnnotationType.swift b/Zotero/Models/AnnotationType.swift index 678a53363..3e446b96a 100644 --- a/Zotero/Models/AnnotationType.swift +++ b/Zotero/Models/AnnotationType.swift @@ -8,9 +8,11 @@ import Foundation -enum AnnotationType: String { +enum AnnotationType: String, CaseIterable { case note case highlight case image case ink + case underline + case freeText = "text" } diff --git a/Zotero/Models/AnnotationsConfig.swift b/Zotero/Models/AnnotationsConfig.swift index 9a1c370b6..b4080edd7 100644 --- a/Zotero/Models/AnnotationsConfig.swift +++ b/Zotero/Models/AnnotationsConfig.swift @@ -13,7 +13,17 @@ import PSPDFKit struct AnnotationsConfig { static let defaultActiveColor = "#ffd400" static let allColors: [String] = ["#ffd400", "#ff6666", "#5fb236", "#2ea8e5", "#a28ae5", "#e56eee", "#f19837", "#aaaaaa", "#000000"] - static let colorNames: [String: String] = ["#ffd400": "Yellow", "#ff6666": "Red", "#5fb236": "Green", "#2ea8e5": "Blue", "#a28ae5": "Purple", "#e56eee": "Magenta", "#f19837": "Orange", "#aaaaaa": "Gray", "#000000": "Black"] + static let colorNames: [String: String] = [ + "#ffd400": "Yellow", + "#ff6666": "Red", + "#5fb236": "Green", + "#2ea8e5": "Blue", + "#a28ae5": "Purple", + "#e56eee": "Magenta", + "#f19837": "Orange", + "#aaaaaa": "Gray", + "#000000": "Black" + ] // Maps different variations colors to their base color static let colorVariationMap: [String: String] = createColorVariationMap() static let keyKey = "Zotero:Key" @@ -22,11 +32,12 @@ struct AnnotationsConfig { // Size of note annotation in PDF document. static let noteAnnotationSize: CGSize = CGSize(width: 22, height: 22) static let positionSizeLimit = 65000 - static let supported: PSPDFKit.Annotation.Kind = [.note, .highlight, .square, .ink] + // TODO: Enable when text/underline annotations are fully available + static let supported: PSPDFKit.Annotation.Kind = [.note, .highlight, .square, .ink]//, .underline, .freeText] static func colors(for type: AnnotationType) -> [String] { switch type { - case .ink: + case .ink, .freeText: return ["#ffd400", "#ff6666", "#5fb236", "#2ea8e5", "#a28ae5", "#e56eee", "#f19837", "#aaaaaa", "#000000"] default: diff --git a/Zotero/Models/Database/Database.swift b/Zotero/Models/Database/Database.swift index 351916b66..1b2acd10b 100644 --- a/Zotero/Models/Database/Database.swift +++ b/Zotero/Models/Database/Database.swift @@ -13,7 +13,7 @@ import RealmSwift import Network struct Database { - private static let schemaVersion: UInt64 = 44 + private static let schemaVersion: UInt64 = 45 static func mainConfiguration(url: URL, fileStorage: FileStorage) -> Realm.Configuration { var config = Realm.Configuration( @@ -83,6 +83,24 @@ struct Database { if schemaVersion < 42 { extractEmojisFromTags(migration: migration) } + if schemaVersion < 45 { + extractAnnotationTypeFromItems(migration: migration) + } + } + } + + private static func extractAnnotationTypeFromItems(migration: Migration) { + migration.enumerateObjects(ofType: RItem.className()) { oldObject, newObject in + guard let rawType = oldObject?["rawType"] as? String, let fields = oldObject?["fields"] as? List else { return } + + switch rawType { + case ItemTypes.annotation: + guard let annotationType = fields.first(where: { $0["key"] as? String == FieldKeys.Item.Annotation.type })?["value"] as? String, !annotationType.isEmpty else { return } + newObject?["annotationType"] = annotationType + + default: + return + } } } diff --git a/Zotero/Models/Database/RItem.swift b/Zotero/Models/Database/RItem.swift index c7eb1b6d3..30286c754 100644 --- a/Zotero/Models/Database/RItem.swift +++ b/Zotero/Models/Database/RItem.swift @@ -119,6 +119,8 @@ final class RItem: Object { /// Indicates whether this instance has nonempty publicationTitle, helper variable, used in sorting so that we can show items with titles /// first and sort them in any order we want (asd/desc) and all other items later @Persisted var hasPublicationTitle: Bool + /// Type of annotation + @Persisted var annotationType: String /// Sort index for annotations @Persisted(indexed: true) var annotationSortIndex: String // MARK: - Sync data diff --git a/Zotero/Models/Defaults.swift b/Zotero/Models/Defaults.swift index 24ff7959f..db452c633 100644 --- a/Zotero/Models/Defaults.swift +++ b/Zotero/Models/Defaults.swift @@ -93,6 +93,9 @@ final class Defaults { @UserDefault(key: "PdfReaderEraserSize", defaultValue: 10) var activeEraserSize: Float + @UserDefault(key: "PdfReaderFontSize", defaultValue: 12) + var activeFontSize: Float + @UserDefault(key: "PDFReaderState.highlightColor", defaultValue: AnnotationsConfig.defaultActiveColor) var highlightColorHex: String @@ -105,6 +108,12 @@ final class Defaults { @UserDefault(key: "PDFReaderState.inkColor", defaultValue: AnnotationsConfig.defaultActiveColor) var inkColorHex: String + @UserDefault(key: "PDFReaderState.underlineColor", defaultValue: AnnotationsConfig.defaultActiveColor) + var underlineColorHex: String + + @UserDefault(key: "PDFReaderState.textColor", defaultValue: AnnotationsConfig.defaultActiveColor) + var textColorHex: String + @CodableUserDefault(key: "PDFReaderSettings", defaultValue: PDFSettings.default, encoder: Defaults.jsonEncoder, decoder: Defaults.jsonDecoder, defaults: .standard) var pdfSettings: PDFSettings #endif diff --git a/Zotero/Models/FieldKeys.swift b/Zotero/Models/FieldKeys.swift index 27f0d5556..14d7362f9 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 rotation = "rotation" + static let fontSize = "fontSize" } static let type = "annotationType" @@ -72,30 +74,44 @@ struct FieldKeys { static func mandatoryApiFields(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.sortIndex, baseKey: nil), - KeyBaseKeyPair(key: Annotation.text, baseKey: nil)] + case .highlight, .underline: + return [ + KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), + KeyBaseKeyPair(key: Annotation.text, baseKey: nil) + ] case .ink: - return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), - KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), - KeyBaseKeyPair(key: Annotation.color, baseKey: nil), - KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil)] + return [ + KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil) + ] case .note, .image: - return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), - KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), - KeyBaseKeyPair(key: Annotation.color, baseKey: nil), - KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil)] + return [ + KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil) + ] + + case .freeText: + return [ + KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil) + ] } } static func allPDFFields(for type: AnnotationType) -> [KeyBaseKeyPair] { switch type { - case .highlight: + case .highlight, .underline: return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), KeyBaseKeyPair(key: Annotation.color, baseKey: nil), @@ -120,6 +136,16 @@ struct FieldKeys { KeyBaseKeyPair(key: Annotation.pageLabel, baseKey: nil), KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), KeyBaseKeyPair(key: Annotation.Position.pageIndex, baseKey: Annotation.position)] + + case .freeText: + 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.pageIndex, baseKey: Annotation.position), + KeyBaseKeyPair(key: Annotation.Position.fontSize, baseKey: Annotation.position), + KeyBaseKeyPair(key: Annotation.Position.rotation, baseKey: Annotation.position)] } } } diff --git a/Zotero/Models/UpdatableObject.swift b/Zotero/Models/UpdatableObject.swift index fc0f522fc..4ff83ec6d 100644 --- a/Zotero/Models/UpdatableObject.swift +++ b/Zotero/Models/UpdatableObject.swift @@ -243,7 +243,7 @@ extension RItem: Updatable { } jsonData[FieldKeys.Item.Annotation.Position.paths] = apiPaths - case .highlight, .image, .note: + case .highlight, .image, .note, .underline, .freeText: var rectArray: [[Double]] = [] self.rects.forEach { rRect in rectArray.append([rRect.minX, rRect.minY, rRect.maxX, rRect.maxY]) diff --git a/Zotero/Scenes/Detail/Annotation Popover/AnnotationPopoverCoordinator.swift b/Zotero/Scenes/Detail/Annotation Popover/AnnotationPopoverCoordinator.swift index 8e2f87243..52ef7ea0a 100644 --- a/Zotero/Scenes/Detail/Annotation Popover/AnnotationPopoverCoordinator.swift +++ b/Zotero/Scenes/Detail/Annotation Popover/AnnotationPopoverCoordinator.swift @@ -16,11 +16,13 @@ protocol AnnotationPopoverAnnotationCoordinatorDelegate: AnyObject { func createShareAnnotationMenu(sender: UIButton) -> UIMenu? func showEdit(state: AnnotationPopoverState, saveAction: @escaping AnnotationEditSaveAction, deleteAction: @escaping AnnotationEditDeleteAction) func showTagPicker(libraryId: LibraryIdentifier, selected: Set, picked: @escaping ([Tag]) -> Void) + func showFontSizePicker(picked: @escaping (UInt) -> Void) func didFinish() } protocol AnnotationEditCoordinatorDelegate: AnyObject { func showPageLabelEditor(label: String, updateSubsequentPages: Bool, saveAction: @escaping AnnotationPageLabelSaveAction) + func showFontSizePicker(picked: @escaping (UInt) -> Void) } final class AnnotationPopoverCoordinator: NSObject, Coordinator { @@ -62,6 +64,11 @@ final class AnnotationPopoverCoordinator: NSObject, Coordinator { } extension AnnotationPopoverCoordinator: AnnotationPopoverAnnotationCoordinatorDelegate { + func showFontSizePicker(picked: @escaping (UInt) -> Void) { + let controller = FontSizePickerViewController(pickAction: picked) + self.navigationController?.pushViewController(controller, animated: true) + } + func createShareAnnotationMenu(sender: UIButton) -> UIMenu? { return (parentCoordinator as? PDFCoordinator)?.createShareAnnotationMenuForSelectedAnnotation(sender: sender) } @@ -73,12 +80,13 @@ extension AnnotationPopoverCoordinator: AnnotationPopoverAnnotationCoordinatorDe color: state.color, lineWidth: state.lineWidth, pageLabel: state.pageLabel, - highlightText: state.highlightText + highlightText: state.highlightText, + fontSize: nil ) let state = AnnotationEditState(data: data) let handler = AnnotationEditActionHandler() let viewModel = ViewModel(initialState: state, handler: handler) - let controller = AnnotationEditViewController(viewModel: viewModel, includeColorPicker: false, saveAction: saveAction, deleteAction: deleteAction) + let controller = AnnotationEditViewController(viewModel: viewModel, includeColorPicker: false, includeFontPicker: false, saveAction: saveAction, deleteAction: deleteAction) controller.coordinatorDelegate = self self.navigationController?.pushViewController(controller, animated: true) } diff --git a/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditAction.swift b/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditAction.swift index 25711e386..69ab2acdc 100644 --- a/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditAction.swift +++ b/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditAction.swift @@ -13,4 +13,5 @@ enum AnnotationEditAction { case setLineWidth(CGFloat) case setPageLabel(String, Bool) case setHighlight(String) + case setFontSize(UInt) } diff --git a/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditState.swift b/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditState.swift index ff6fa56ff..f101b94b0 100644 --- a/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditState.swift +++ b/Zotero/Scenes/Detail/Annotation Popover/Models/AnnotationEditState.swift @@ -16,6 +16,7 @@ struct AnnotationEditState: ViewModelState { let lineWidth: CGFloat let pageLabel: String let highlightText: String + let fontSize: UInt? } struct Changes: OptionSet { @@ -33,22 +34,24 @@ struct AnnotationEditState: ViewModelState { var color: String var lineWidth: CGFloat var pageLabel: String + var fontSize: UInt var highlightText: String var updateSubsequentLabels: Bool var changes: Changes init(data: Data) { - self.type = data.type - self.isEditable = data.isEditable - self.color = data.color - self.lineWidth = data.lineWidth - self.pageLabel = data.pageLabel - self.highlightText = data.highlightText - self.updateSubsequentLabels = false - self.changes = [] + type = data.type + isEditable = data.isEditable + color = data.color + lineWidth = data.lineWidth + pageLabel = data.pageLabel + highlightText = data.highlightText + fontSize = data.fontSize ?? 0 + updateSubsequentLabels = false + changes = [] } mutating func cleanup() { - self.changes = [] + changes = [] } } diff --git a/Zotero/Scenes/Detail/Annotation Popover/ViewModels/AnnotationEditActionHandler.swift b/Zotero/Scenes/Detail/Annotation Popover/ViewModels/AnnotationEditActionHandler.swift index 32aedc36f..ef76ccddd 100644 --- a/Zotero/Scenes/Detail/Annotation Popover/ViewModels/AnnotationEditActionHandler.swift +++ b/Zotero/Scenes/Detail/Annotation Popover/ViewModels/AnnotationEditActionHandler.swift @@ -36,6 +36,11 @@ struct AnnotationEditActionHandler: ViewModelActionHandler { self.update(viewModel: viewModel) { state in state.highlightText = text } + + case .setFontSize(let size): + self.update(viewModel: viewModel) { state in + state.fontSize = size + } } } } diff --git a/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationEditViewController.swift b/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationEditViewController.swift index 9136d17c9..a8ae7975e 100644 --- a/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationEditViewController.swift +++ b/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationEditViewController.swift @@ -10,12 +10,13 @@ import UIKit import RxSwift -typealias AnnotationEditSaveAction = (String, CGFloat, String, Bool, String) -> Void // key, color, lineWidth, pageLabel, updateSubsequentLabels, highlightText +// key, color, lineWidth, fontSize, pageLabel, updateSubsequentLabels, highlightText +typealias AnnotationEditSaveAction = (String, CGFloat, UInt, String, Bool, String) -> Void typealias AnnotationEditDeleteAction = () -> Void final class AnnotationEditViewController: UIViewController { private enum Section { - case properties, pageLabel, actions, highlight + case properties, pageLabel, actions, highlight, fontSize func cellId(index: Int) -> String { switch self { @@ -23,6 +24,7 @@ final class AnnotationEditViewController: UIViewController { case .actions: return "ActionCell" case .pageLabel: return "PageLabelCell" case .highlight: return "HighlightCell" + case .fontSize: return "FontSizeCell" } } } @@ -37,13 +39,18 @@ final class AnnotationEditViewController: UIViewController { weak var coordinatorDelegate: AnnotationEditCoordinatorDelegate? - init(viewModel: ViewModel, includeColorPicker: Bool, saveAction: @escaping AnnotationEditSaveAction, deleteAction: @escaping AnnotationEditDeleteAction) { + init(viewModel: ViewModel, includeColorPicker: Bool, includeFontPicker: Bool, saveAction: @escaping AnnotationEditSaveAction, deleteAction: @escaping AnnotationEditDeleteAction) { var sections: [Section] = [.pageLabel, .actions] - if includeColorPicker && viewModel.state.isEditable { - sections.insert(.properties, at: 0) - } - if viewModel.state.type == .highlight && viewModel.state.isEditable { - sections.insert(.highlight, at: 0) + if viewModel.state.isEditable { + if includeColorPicker { + sections.insert(.properties, at: 0) + } + if includeFontPicker { + sections.insert(.fontSize, at: 0) + } + if viewModel.state.type == .highlight { + sections.insert(.highlight, at: 0) + } } self.viewModel = viewModel @@ -165,9 +172,9 @@ final class AnnotationEditViewController: UIViewController { let save = UIBarButtonItem(title: L10n.save, style: .done, target: nil, action: nil) save.rx.tap .subscribe(onNext: { [weak self] in - guard let self = self else { return } - let state = self.viewModel.state - self.saveAction(state.color, state.lineWidth, state.pageLabel, state.updateSubsequentLabels, state.highlightText) + guard let self else { return } + let state = viewModel.state + saveAction(state.color, state.lineWidth, state.fontSize, state.pageLabel, state.updateSubsequentLabels, state.highlightText) self.cancel() }) .disposed(by: self.disposeBag) @@ -182,6 +189,7 @@ final class AnnotationEditViewController: UIViewController { self.tableView.register(ColorPickerCell.self, forCellReuseIdentifier: Section.properties.cellId(index: 0)) self.tableView.register(LineWidthCell.self, forCellReuseIdentifier: Section.properties.cellId(index: 1)) self.tableView.register(UINib(nibName: "HighlightEditCell", bundle: nil), forCellReuseIdentifier: Section.highlight.cellId(index: 0)) + self.tableView.register(FontSizeCell.self, forCellReuseIdentifier: Section.fontSize.cellId(index: 0)) self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: Section.actions.cellId(index: 0)) self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: Section.pageLabel.cellId(index: 0)) self.tableView.setDefaultSizedHeader() @@ -233,6 +241,12 @@ extension AnnotationEditViewController: UITableViewDataSource { .disposed(by: self.disposeBag) } + case .fontSize: + if let cell = cell as? FontSizeCell { + cell.set(value: self.viewModel.state.fontSize) + cell.valueObservable.subscribe(onNext: { value in self.viewModel.process(action: .setFontSize(value)) }).disposed(by: cell.disposeBag) + } + case .pageLabel: cell.textLabel?.text = L10n.page + " " + self.viewModel.state.pageLabel if self.isEditing { @@ -264,9 +278,18 @@ extension AnnotationEditViewController: UITableViewDelegate { case .pageLabel: guard self.viewModel.state.isEditable else { return } - self.coordinatorDelegate?.showPageLabelEditor(label: self.viewModel.state.pageLabel, updateSubsequentPages: self.viewModel.state.updateSubsequentLabels, - saveAction: { [weak self] newLabel, shouldUpdateSubsequentPages in - self?.viewModel.process(action: .setPageLabel(newLabel, shouldUpdateSubsequentPages)) + self.coordinatorDelegate?.showPageLabelEditor( + label: self.viewModel.state.pageLabel, + updateSubsequentPages: self.viewModel.state.updateSubsequentLabels, + saveAction: { [weak self] newLabel, shouldUpdateSubsequentPages in + self?.viewModel.process(action: .setPageLabel(newLabel, shouldUpdateSubsequentPages)) + } + ) + + case .fontSize: + self.coordinatorDelegate?.showFontSizePicker(picked: { [weak self, weak tableView] newSize in + self?.viewModel.process(action: .setFontSize(newSize)) + tableView?.reloadRows(at: [indexPath], with: .none) }) } } diff --git a/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationPopoverViewController.swift b/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationPopoverViewController.swift index 809407816..76d8ebf3d 100644 --- a/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationPopoverViewController.swift +++ b/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationPopoverViewController.swift @@ -121,9 +121,10 @@ final class AnnotationPopoverViewController: UIViewController { } private func showSettings() { + // key, color, lineWidth, fontSize, pageLabel, updateSubsequentLabels, highlightText coordinatorDelegate?.showEdit( state: viewModel.state, - saveAction: { [weak self] _, _, pageLabel, updateSubsequentLabels, highlightText in + saveAction: { [weak self] _, _, _, pageLabel, updateSubsequentLabels, highlightText in self?.viewModel.process(action: .setProperties(pageLabel: pageLabel, updateSubsequentLabels: updateSubsequentLabels, highlightText: highlightText)) }, deleteAction: { [weak self] in diff --git a/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationViewController.swift b/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationViewController.swift deleted file mode 100644 index 68ad7a6a1..000000000 --- a/Zotero/Scenes/Detail/Annotation Popover/Views/AnnotationViewController.swift +++ /dev/null @@ -1,345 +0,0 @@ -// -// AnnotationViewController.swift -// Zotero -// -// Created by Michal Rentka on 13/10/2020. -// Copyright © 2020 Corporation for Digital Scholarship. All rights reserved. -// - -import UIKit - -import RxSwift -import CocoaLumberjackSwift - -final class AnnotationViewController: UIViewController { - let annotationKey: PDFReaderState.AnnotationKey? - private let viewModel: ViewModel - private unowned let attributedStringConverter: HtmlAttributedStringConverter - private let disposeBag: DisposeBag - - @IBOutlet private weak var scrollView: UIScrollView! - @IBOutlet private weak var containerStackView: UIStackView! - private weak var header: AnnotationViewHeader! - private weak var comment: AnnotationViewTextView? - private weak var colorPicker: ColorPickerStackView! - private weak var tagsButton: AnnotationViewButton! - private weak var tags: AnnotationViewText! - private weak var deleteButton: UIButton! - - weak var coordinatorDelegate: AnnotationPopoverAnnotationCoordinatorDelegate? - - private var commentPlaceholder: String { - let canEdit = self.viewModel.state.selectedAnnotation?.editability(currentUserId: self.viewModel.state.userId, library: self.viewModel.state.library) == .editable - return canEdit ? L10n.Pdf.AnnotationsSidebar.addComment : L10n.Pdf.AnnotationPopover.noComment - } - - // MARK: - Lifecycle - - init(viewModel: ViewModel, attributedStringConverter: HtmlAttributedStringConverter) { - self.viewModel = viewModel - self.annotationKey = viewModel.state.selectedAnnotationKey - self.attributedStringConverter = attributedStringConverter - 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.setupViews() - self.view.layoutSubviews() - - self.viewModel.stateObservable - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] state in - self?.update(state: state) - }) - .disposed(by: self.disposeBag) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.navigationController?.setNavigationBarHidden(true, animated: animated) - self.updatePreferredContentSize() - } - - deinit { - DDLogInfo("AnnotationViewController: deinitialized") - self.coordinatorDelegate?.didFinish() - } - - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - - self.updatePreferredContentSize() - } - - // MARK: - Actions - - private func updatePreferredContentSize() { - guard var size = self.containerStackView?.systemLayoutSizeFitting(CGSize(width: AnnotationPopoverLayout.width, height: .greatestFiniteMagnitude)) else { return } - size.width = AnnotationPopoverLayout.width - self.preferredContentSize = size - self.navigationController?.preferredContentSize = size - } - - private func update(state: PDFReaderState) { - guard state.changes.contains(.annotations), let annotation = state.selectedAnnotation else { return } - - // Update header - let editability = annotation.editability(currentUserId: state.userId, library: state.library) - self.header.setup( - type: annotation.type, - authorName: annotation.author(displayName: state.displayName, username: state.username), - pageLabel: annotation.pageLabel, - colorHex: annotation.color, - libraryId: state.library.identifier, - shareMenuProvider: { [weak self] button in - self?.createShareAnnotationMenu(sender: button) - }, - isEditable: (editability == .editable), - showsLock: (editability != .editable), - accessibilityType: .view - ) - - // Update selected color - colorPicker.setSelected(hexColor: annotation.color) - - // Update tags - if !annotation.tags.isEmpty { - self.tags.setup(with: AnnotationView.attributedString(from: annotation.tags, layout: AnnotationPopoverLayout.annotationLayout)) - } - self.tags.isHidden = annotation.tags.isEmpty - self.tagsButton?.isHidden = !annotation.tags.isEmpty - } - - private func name(for color: String, isSelected: Bool) -> String { - let colorName = AnnotationsConfig.colorNames[color] ?? L10n.unknown - return !isSelected ? colorName : L10n.Accessibility.Pdf.selected + ": " + colorName - } - - @objc private func deleteAnnotation() { - guard let key = self.viewModel.state.selectedAnnotationKey else { return } - self.viewModel.process(action: .removeAnnotation(key)) - } - - private func createShareAnnotationMenu(sender: UIButton) -> UIMenu? { - coordinatorDelegate?.createShareAnnotationMenu(sender: sender) - } - - private func showSettings() { - guard let annotation = self.viewModel.state.selectedAnnotation else { return } - let key = annotation.readerKey - self.coordinatorDelegate?.showEdit( - annotation: annotation, - userId: self.viewModel.state.userId, - library: self.viewModel.state.library, - saveAction: { [weak self] color, lineWidth, pageLabel, updateSubsequentLabels, highlightText in - self?.viewModel.process( - action: .updateAnnotationProperties( - key: key.key, - color: color, - lineWidth: lineWidth, - pageLabel: pageLabel, - updateSubsequentLabels: updateSubsequentLabels, - highlightText: highlightText - ) - ) - }, - deleteAction: { [weak self] in - self?.viewModel.process(action: .removeAnnotation(key)) - } - ) - } - - private func set(color: String) { - guard let annotation = self.viewModel.state.selectedAnnotation else { return } - self.viewModel.process(action: .setColor(key: annotation.key, color: color)) - } - - private func showTagPicker() { - guard let annotation = self.viewModel.state.selectedAnnotation, annotation.isAuthor(currentUserId: self.viewModel.state.userId) else { return } - - let selected = Set(annotation.tags.map({ $0.name })) - self.coordinatorDelegate?.showTagPicker(libraryId: self.viewModel.state.library.identifier, selected: selected, picked: { [weak self] tags in - self?.viewModel.process(action: .setTags(key: annotation.key, tags: tags)) - }) - } - - private func scrollToCursorIfNeeded() { - guard let commentView = self.comment, commentView.textView.isFirstResponder, let selectedPosition = commentView.textView.selectedTextRange?.start else { return } - let caretRect = commentView.textView.caretRect(for: selectedPosition) - guard (commentView.frame.origin.y + caretRect.origin.y) > self.scrollView.frame.height else { return } - - let rect = CGRect(x: caretRect.origin.x, y: (commentView.frame.origin.y + caretRect.origin.y) + 10, width: caretRect.size.width, height: caretRect.size.height) - self.scrollView.scrollRectToVisible(rect, animated: true) - } - - // MARK: - Setups - - private func setupViews() { - guard let annotation = self.viewModel.state.selectedAnnotation else { return } - - let layout = AnnotationPopoverLayout.annotationLayout - - // Setup header - let header = AnnotationViewHeader(layout: layout) - let editability = annotation.editability(currentUserId: self.viewModel.state.userId, library: self.viewModel.state.library) - header.setup( - type: annotation.type, - authorName: annotation.author(displayName: self.viewModel.state.displayName, username: self.viewModel.state.username), - pageLabel: annotation.pageLabel, - colorHex: annotation.color, - libraryId: self.viewModel.state.library.identifier, - shareMenuProvider: { [weak self] button in - self?.createShareAnnotationMenu(sender: button) - }, - isEditable: (editability == .editable), - showsLock: (editability != .editable), - accessibilityType: .view - ) - header.menuTap - .subscribe(with: self, onNext: { `self`, _ in - self.showSettings() - }) - .disposed(by: self.disposeBag) - if let tap = header.doneTap { - tap.subscribe(with: self, onNext: { `self`, _ in - self.presentingViewController?.dismiss(animated: true, completion: nil) - }) - .disposed(by: self.disposeBag) - } - self.header = header - - self.containerStackView.addArrangedSubview(header) - self.containerStackView.addArrangedSubview(AnnotationViewSeparator()) - - // Setup comment - if annotation.type != .ink { - let commentView = AnnotationViewTextView(layout: layout, placeholder: self.commentPlaceholder) - let comment = AnnotationView.attributedString(from: self.attributedStringConverter.convert(text: annotation.comment, baseAttributes: [.font: layout.font]), layout: layout) - commentView.setup(text: comment) - commentView.isUserInteractionEnabled = editability == .editable - commentView.textObservable - .debounce(.milliseconds(500), scheduler: MainScheduler.instance) - .subscribe(onNext: { [weak self] data in - guard let self, let (text, needsHeightReload) = data else { return } - viewModel.process(action: .setComment(key: annotation.key, comment: text)) - if needsHeightReload { - updatePreferredContentSize() - scrollToCursorIfNeeded() - } - }) - .disposed(by: disposeBag) - self.comment = commentView - - self.containerStackView.addArrangedSubview(commentView) - self.containerStackView.addArrangedSubview(AnnotationViewSeparator()) - } - - // Setup color picker - if editability == .editable { - let colorPickerContainer = UIView() - colorPickerContainer.backgroundColor = Asset.Colors.defaultCellBackground.color - colorPickerContainer.accessibilityLabel = L10n.Accessibility.Pdf.colorPicker - - let hexColors = AnnotationsConfig.colors(for: annotation.type) - let colorPicker = ColorPickerStackView( - hexColors: hexColors, - columnsDistribution: .fixed(numberOfColumns: hexColors.count), - allowsMultipleSelection: false, - circleBackgroundColor: Asset.Colors.defaultCellBackground.color, - circleContentInsets: UIEdgeInsets(top: 11, left: 11, bottom: 11, right: 11), - accessibilityLabelProvider: { [weak self] hexColor, isSelected in - self?.name(for: hexColor, isSelected: isSelected) - }, - hexColorToggled: { [weak self] hexColor in - self?.set(color: hexColor) - } - ) - colorPicker.setSelected(hexColor: viewModel.state.selectedAnnotation?.color) - colorPicker.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) - colorPicker.translatesAutoresizingMaskIntoConstraints = false - self.colorPicker = colorPicker - colorPickerContainer.addSubview(colorPicker) - - NSLayoutConstraint.activate([ - colorPicker.topAnchor.constraint(equalTo: colorPickerContainer.topAnchor), - colorPicker.bottomAnchor.constraint(equalTo: colorPickerContainer.bottomAnchor), - colorPicker.leadingAnchor.constraint(equalTo: colorPickerContainer.leadingAnchor, constant: 5), - colorPicker.trailingAnchor.constraint(lessThanOrEqualTo: colorPickerContainer.trailingAnchor) - ]) - - self.containerStackView.addArrangedSubview(colorPickerContainer) - self.containerStackView.addArrangedSubview(AnnotationViewSeparator()) - - if annotation.type == .ink { - // Setup line width slider - let lineView = LineWidthView(title: L10n.Pdf.AnnotationPopover.lineWidth, settings: .lineWidth, contentInsets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)) - lineView.value = Float(annotation.lineWidth ?? 0) - lineView.valueObservable - .subscribe(with: self, onNext: { `self`, value in - self.viewModel.process(action: .setLineWidth(key: annotation.key, width: CGFloat(value))) - }) - .disposed(by: self.disposeBag) - self.containerStackView.addArrangedSubview(lineView) - self.containerStackView.addArrangedSubview(AnnotationViewSeparator()) - } - } - - // Setup tags - let tags = AnnotationViewText(layout: layout) - if !annotation.tags.isEmpty { - tags.setup(with: AnnotationView.attributedString(from: annotation.tags, layout: layout)) - } - tags.isHidden = annotation.tags.isEmpty - tags.isEnabled = editability == .editable - tags.tap - .subscribe(with: self, onNext: { `self`, _ in - self.showTagPicker() - }) - .disposed(by: self.disposeBag) - tags.button.accessibilityLabel = L10n.Accessibility.Pdf.tags + ": " + (self.tags?.textLabel.text ?? "") - tags.textLabel.isAccessibilityElement = false - self.tags = tags - - self.containerStackView.addArrangedSubview(tags) - - if editability == .editable { - let tagButton = AnnotationViewButton(layout: layout) - tagButton.setTitle(L10n.Pdf.AnnotationsSidebar.addTags, for: .normal) - tagButton.isHidden = !annotation.tags.isEmpty - tagButton.rx.tap - .subscribe(with: self, onNext: { `self`, _ in - self.showTagPicker() - }) - .disposed(by: self.disposeBag) - tagButton.accessibilityLabel = L10n.Pdf.AnnotationsSidebar.addTags - self.tagsButton = tagButton - - self.containerStackView.addArrangedSubview(tagButton) - self.containerStackView.addArrangedSubview(AnnotationViewSeparator()) - } - - if editability != .notEditable { - var configuration = UIButton.Configuration.plain() - configuration.attributedTitle = AttributedString(L10n.Pdf.AnnotationPopover.delete, attributes: AttributeContainer([.font: UIFont.preferredFont(forTextStyle: .body)])) - configuration.baseForegroundColor = .red - configuration.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0) - let button = UIButton() - button.configuration = configuration - button.addTarget(self, action: #selector(AnnotationViewController.deleteAnnotation), for: .touchUpInside) - button.titleLabel?.adjustsFontForContentSizeCategory = true - self.deleteButton = button - - self.containerStackView.addArrangedSubview(button) - } - } -} - -extension AnnotationViewController: AnnotationPopover {} diff --git a/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeCell.swift b/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeCell.swift new file mode 100644 index 000000000..d1938b7f5 --- /dev/null +++ b/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeCell.swift @@ -0,0 +1,46 @@ +// +// FontSizeCell.swift +// Zotero +// +// Created by Michal Rentka on 01.08.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import RxSwift + +class FontSizeCell: RxTableViewCell { + private weak var fontSizeView: FontSizeView! + var tapObservable: PublishSubject<()> { return self.fontSizeView.tapObservable } + var valueObservable: PublishSubject { return self.fontSizeView.valueObservable } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + self.setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setup() + } + + func set(value: UInt) { + self.fontSizeView.value = value + } + + private func setup() { + let fontSizeView = FontSizeView(contentInsets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16), stepperEnabled: true) + fontSizeView.translatesAutoresizingMaskIntoConstraints = false + fontSizeView.button.isUserInteractionEnabled = false + self.fontSizeView = fontSizeView + self.contentView.addSubview(fontSizeView) + + NSLayoutConstraint.activate([ + self.contentView.topAnchor.constraint(equalTo: fontSizeView.topAnchor), + self.contentView.bottomAnchor.constraint(equalTo: fontSizeView.bottomAnchor), + self.contentView.leadingAnchor.constraint(equalTo: fontSizeView.leadingAnchor), + self.contentView.trailingAnchor.constraint(equalTo: fontSizeView.trailingAnchor) + ]) + } +} diff --git a/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizePickerViewController.swift b/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizePickerViewController.swift new file mode 100644 index 000000000..9b129b9ff --- /dev/null +++ b/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizePickerViewController.swift @@ -0,0 +1,118 @@ +// +// FontSizePickerViewController.swift +// Zotero +// +// Created by Michal Rentka on 01.08.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import RxSwift + +class FontSizePickerViewController: UIViewController { + private static let sizes: [UInt] = [ + 10, + 12, + 14, + 18, + 24, + 36, + 48, + 64, + 72, + 96, + 144, + 192 + ] + + private let pickAction: (UInt) -> Void + private let disposeBag: DisposeBag + + private weak var tableView: UITableView! + private var dataSource: UITableViewDiffableDataSource! + + init(pickAction: @escaping (UInt) -> Void) { + self.pickAction = pickAction + 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() + + setupNavigationBarIfNeeded() + setupViews() + setupSizes() + + func setupSizes() { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(FontSizePickerViewController.sizes) + dataSource.apply(snapshot, animatingDifferences: false) + } + + func setupViews() { + let tableView = UITableView() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { tableView, indexPath, size in + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + var configuration = cell.defaultContentConfiguration() + configuration.text = "\(size)pt" + cell.contentConfiguration = configuration + return cell + }) + tableView.dataSource = self.dataSource + tableView.delegate = self + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: tableView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor), + view.topAnchor.constraint(equalTo: tableView.topAnchor), + view.bottomAnchor.constraint(equalTo: tableView.bottomAnchor) + ]) + } + + func setupNavigationBarIfNeeded() { + // Check whether this controller is used in UINavigationController container which only contains this controller. Otherwise if this controller was pushed into navigation stack we don't + // need the cancel button. + guard let navigationController, navigationController.viewControllers.count == 1 else { return } + let cancel = UIBarButtonItem(systemItem: .cancel) + cancel + .rx + .tap + .subscribe(with: self, onNext: { `self`, _ in + self.navigationController?.presentingViewController?.dismiss(animated: true) + }) + .disposed(by: disposeBag) + self.navigationItem.leftBarButtonItem = cancel + } + } + + override func loadView() { + self.view = UIView() + } +} + +extension FontSizePickerViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let size = dataSource.itemIdentifier(for: indexPath) else { return } + self.pickAction(size) + if let controller = navigationController { + if controller.viewControllers.count == 1 { + controller.presentingViewController?.dismiss(animated: true) + } else { + controller.popViewController(animated: true) + } + } else { + self.presentingViewController?.dismiss(animated: true) + } + } +} diff --git a/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeView.swift b/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeView.swift new file mode 100644 index 000000000..beae784aa --- /dev/null +++ b/Zotero/Scenes/Detail/Annotation Popover/Views/FontSizeView.swift @@ -0,0 +1,113 @@ +// +// FontSizeView.swift +// Zotero +// +// Created by Michal Rentka on 31.07.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import RxSwift +import RxCocoa + +final class FontSizeView: UIView { + private let contentInsets: UIEdgeInsets + private let disposeBag: DisposeBag + let tapObservable: PublishSubject<()> + let valueObservable: PublishSubject + + var stepperEnabled: Bool { + didSet { + self.stepper.isHidden = !self.stepperEnabled + } + } + private(set) weak var button: UIButton! + private weak var stepper: UIStepper! + + var value: UInt { + get { + return UInt(self.stepper.value) + } + + set { + self.stepper.value = Double(newValue) + self.updateLabel(with: newValue) + } + } + + init(contentInsets: UIEdgeInsets, stepperEnabled: Bool) { + self.contentInsets = contentInsets + self.stepperEnabled = stepperEnabled + self.disposeBag = DisposeBag() + self.tapObservable = PublishSubject() + self.valueObservable = PublishSubject() + super.init(frame: CGRect()) + self.setup() + } + + required init?(coder: NSCoder) { + self.contentInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + self.stepperEnabled = true + self.disposeBag = DisposeBag() + self.tapObservable = PublishSubject() + self.valueObservable = PublishSubject() + super.init(coder: coder) + self.setup() + } + + // MARK: - Actions + + private func stepChanged() { + let value = UInt(self.stepper.value) + self.updateLabel(with: value) + self.valueObservable.on(.next(value)) + } + + private func updateLabel(with value: UInt) { + let valueString = "\(value)" + let ptString = "pt" + let attributedString = NSMutableAttributedString(string: valueString + ptString) + attributedString.addAttributes([.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: UIColor.label], range: NSRange(location: 0, length: valueString.count)) + attributedString.addAttributes([.font: UIFont.preferredFont(forTextStyle: .callout), .foregroundColor: UIColor.darkGray], range: NSRange(location: valueString.count, length: ptString.count)) + self.button.setAttributedTitle(attributedString, for: .normal) + } + + // MARK: - Setups + + private func setup() { + let stepper = UIStepper() + stepper.isHidden = !self.stepperEnabled + stepper.stepValue = 1 + stepper.minimumValue = 1 + stepper.maximumValue = 200 + stepper.rx.controlEvent(.valueChanged) + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { `self`, _ in + self.stepChanged() + }) + .disposed(by: self.disposeBag) + self.stepper = stepper + + let button = UIButton() + button.contentHorizontalAlignment = .leading + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.rx.tap.bind(to: self.tapObservable).disposed(by: self.disposeBag) + self.button = button + + let container = UIStackView(arrangedSubviews: [button, stepper]) + container.axis = .horizontal + container.alignment = .center + container.spacing = 12 + container.translatesAutoresizingMaskIntoConstraints = false + + self.addSubview(container) + + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: self.topAnchor, constant: self.contentInsets.top), + self.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: self.contentInsets.bottom), + container.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: self.contentInsets.left), + self.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: self.contentInsets.right) + ]) + } +} diff --git a/Zotero/Scenes/Detail/Annotation Popover/Views/LineWidthView.swift b/Zotero/Scenes/Detail/Annotation Popover/Views/LineWidthView.swift index 0c7757f57..d696e8ecf 100644 --- a/Zotero/Scenes/Detail/Annotation Popover/Views/LineWidthView.swift +++ b/Zotero/Scenes/Detail/Annotation Popover/Views/LineWidthView.swift @@ -32,6 +32,12 @@ final class LineWidthView: UIView { return ceil(value) }) } + + static var fontSize: Settings { + return LineWidthView.Settings(minValue: 1, maxValue: 300, stepFunction: { value in + return round(value) + }) + } } private let contentInsets: UIEdgeInsets diff --git a/Zotero/Scenes/Detail/PDF/AnnotationEditCoordinator.swift b/Zotero/Scenes/Detail/PDF/AnnotationEditCoordinator.swift index bf0d43a73..200ef56a9 100644 --- a/Zotero/Scenes/Detail/PDF/AnnotationEditCoordinator.swift +++ b/Zotero/Scenes/Detail/PDF/AnnotationEditCoordinator.swift @@ -50,13 +50,24 @@ final class AnnotationEditCoordinator: Coordinator { let state = AnnotationEditState(data: data) let handler = AnnotationEditActionHandler() let viewModel = ViewModel(initialState: state, handler: handler) - let controller = AnnotationEditViewController(viewModel: viewModel, includeColorPicker: true, saveAction: self.saveAction, deleteAction: self.deleteAction) + let controller = AnnotationEditViewController( + viewModel: viewModel, + includeColorPicker: true, + includeFontPicker: data.type == .freeText, + saveAction: self.saveAction, + deleteAction: self.deleteAction + ) controller.coordinatorDelegate = self self.navigationController?.setViewControllers([controller], animated: false) } } extension AnnotationEditCoordinator: AnnotationEditCoordinatorDelegate { + func showFontSizePicker(picked: @escaping (UInt) -> Void) { + let controller = FontSizePickerViewController(pickAction: picked) + self.navigationController?.pushViewController(controller, animated: true) + } + func showPageLabelEditor(label: String, updateSubsequentPages: Bool, saveAction: @escaping AnnotationPageLabelSaveAction) { let state = AnnotationPageLabelState(label: label, updateSubsequentPages: updateSubsequentPages) let handler = AnnotationPageLabelActionHandler() diff --git a/Zotero/Scenes/Detail/PDF/Models/PDFAnnotation.swift b/Zotero/Scenes/Detail/PDF/Models/PDFAnnotation.swift index b1ac1ec61..d1ade61d5 100644 --- a/Zotero/Scenes/Detail/PDF/Models/PDFAnnotation.swift +++ b/Zotero/Scenes/Detail/PDF/Models/PDFAnnotation.swift @@ -18,6 +18,8 @@ protocol PDFAnnotation { var color: String { get } var comment: String { get } var text: String? { get } + var fontSize: UInt? { get } + var rotation: UInt? { get } var sortIndex: String { get } var dateModified: Date { get } var isSyncable: Bool { get } @@ -40,7 +42,10 @@ extension PDFAnnotation { case .ink: return AnnotationPreviewBoundingBoxCalculator.inkPreviewRect(from: boundingBox) - case .note, .highlight: + case .freeText: + return AnnotationPreviewBoundingBoxCalculator.freeTextPreviewRect(from: boundingBox, rotation: self.rotation ?? 0) + + case .note, .highlight, .underline: return boundingBox.rounded(to: 3) } } @@ -52,7 +57,7 @@ extension PDFAnnotation { let lineWidth = self.lineWidth ?? 1 return AnnotationBoundingBoxCalculator.boundingBox(from: paths, lineWidth: lineWidth).rounded(to: 3) - case .note, .image, .highlight: + case .note, .image, .highlight, .underline, .freeText: let rects = self.rects(boundingBoxConverter: boundingBoxConverter) if rects.count == 1 { return rects[0].rounded(to: 3) diff --git a/Zotero/Scenes/Detail/PDF/Models/PDFDatabaseAnnotation.swift b/Zotero/Scenes/Detail/PDF/Models/PDFDatabaseAnnotation.swift index 42c7f5ae7..fafba0cfe 100644 --- a/Zotero/Scenes/Detail/PDF/Models/PDFDatabaseAnnotation.swift +++ b/Zotero/Scenes/Detail/PDF/Models/PDFDatabaseAnnotation.swift @@ -14,30 +14,31 @@ import RxSwift struct PDFDatabaseAnnotation { let item: RItem + let type: AnnotationType - var key: String { - return self.item.key - } - - var _type: AnnotationType? { - guard let rawValue = self.item.fieldValue(for: FieldKeys.Item.Annotation.type) else { - DDLogError("DatabaseAnnotation: \(self.key) missing annotation type!") + init?(item: RItem) { + guard let type = AnnotationType(rawValue: item.annotationType) else { + DDLogWarn("DatabaseAnnotation: \(item.key) unknown annotation type \(item.annotationType)") return nil } - guard let type = AnnotationType(rawValue: rawValue) else { - DDLogWarn("DatabaseAnnotation: \(self.key) unknown annotation type \(rawValue)") + guard AnnotationsConfig.supported.contains(type.kind) else { return nil } - return type + self.item = item + self.type = type + } + + var key: String { + return item.key } var _page: Int? { - guard let rawValue = self.item.fieldValue(for: FieldKeys.Item.Annotation.Position.pageIndex) else { - DDLogError("DatabaseAnnotation: \(self.key) missing page!") + guard let rawValue = item.fieldValue(for: FieldKeys.Item.Annotation.Position.pageIndex) else { + DDLogError("DatabaseAnnotation: \(key) missing page!") return nil } guard let page = Int(rawValue) else { - DDLogError("DatabaseAnnotation: \(self.key) page incorrect format \(rawValue)") + DDLogError("DatabaseAnnotation: \(key) page incorrect format \(rawValue)") // Page is not an int, try double or fail return Double(rawValue).flatMap(Int.init) } @@ -45,19 +46,19 @@ struct PDFDatabaseAnnotation { } var _pageLabel: String? { - guard let label = self.item.fieldValue(for: FieldKeys.Item.Annotation.pageLabel) else { - DDLogError("DatabaseAnnotation: \(self.key) missing page label!") + guard let label = item.fieldValue(for: FieldKeys.Item.Annotation.pageLabel) else { + DDLogError("DatabaseAnnotation: \(key) missing page label!") return nil } return label } var lineWidth: CGFloat? { - return (self.item.fields.filter(.key(FieldKeys.Item.Annotation.Position.lineWidth)).first?.value).flatMap(Double.init).flatMap(CGFloat.init) + return (item.fields.filter(.key(FieldKeys.Item.Annotation.Position.lineWidth)).first?.value).flatMap(Double.init).flatMap(CGFloat.init) } func isAuthor(currentUserId: Int) -> Bool { - return self.item.libraryId == .custom(.myLibrary) ? true : self.item.createdBy?.identifier == currentUserId + return item.libraryId == .custom(.myLibrary) ? true : item.createdBy?.identifier == currentUserId } func author(displayName: String, username: String) -> String { @@ -65,7 +66,7 @@ struct PDFDatabaseAnnotation { return authorName } - if let createdBy = self.item.createdBy { + if let createdBy = item.createdBy { if !createdBy.name.isEmpty { return createdBy.name } @@ -87,31 +88,40 @@ struct PDFDatabaseAnnotation { } var _color: String? { - guard let color = self.item.fieldValue(for: FieldKeys.Item.Annotation.color) else { - DDLogError("DatabaseAnnotation: \(self.key) missing color!") + guard let color = item.fieldValue(for: FieldKeys.Item.Annotation.color) else { + DDLogError("DatabaseAnnotation: \(key) missing color!") return nil } return color } var comment: String { - return self.item.fieldValue(for: FieldKeys.Item.Annotation.comment) ?? "" + return item.fieldValue(for: FieldKeys.Item.Annotation.comment) ?? "" } var text: String? { - return self.item.fields.filter(.key(FieldKeys.Item.Annotation.text)).first?.value + return item.fields.filter(.key(FieldKeys.Item.Annotation.text)).first?.value + } + + var fontSize: UInt? { + return (item.fields.filter(.key(FieldKeys.Item.Annotation.Position.fontSize)).first?.value).flatMap(UInt.init) + } + + var rotation: UInt? { + guard let rotation = (item.fields.filter(.key(FieldKeys.Item.Annotation.Position.rotation)).first?.value).flatMap(Double.init) else { return nil } + return UInt(round(rotation)) } var sortIndex: String { - return self.item.annotationSortIndex + return item.annotationSortIndex } var dateModified: Date { - return self.item.dateModified + return item.dateModified } var tags: [Tag] { - return self.item.tags.map({ Tag(tag: $0) }) + return item.tags.map({ Tag(tag: $0) }) } func editability(currentUserId: Int, library: Library) -> AnnotationEditability { @@ -123,21 +133,22 @@ struct PDFDatabaseAnnotation { if !library.metadataEditable { return .notEditable } - return self.isAuthor(currentUserId: currentUserId) ? .editable : .deletable + return isAuthor(currentUserId: currentUserId) ? .editable : .deletable } } func rects(boundingBoxConverter: AnnotationBoundingBoxConverter) -> [CGRect] { - guard let page = self._page else { return [] } - return self.item.rects.map({ CGRect(x: $0.minX, y: $0.minY, width: ($0.maxX - $0.minX), height: ($0.maxY - $0.minY)) }) - .compactMap({ boundingBoxConverter.convertFromDb(rect: $0, page: PageIndex(page))?.rounded(to: 3) }) + guard let page = _page else { return [] } + return item.rects + .map({ CGRect(x: $0.minX, y: $0.minY, width: ($0.maxX - $0.minX), height: ($0.maxY - $0.minY)) }) + .compactMap({ boundingBoxConverter.convertFromDb(rect: $0, page: PageIndex(page))?.rounded(to: 3) }) } func paths(boundingBoxConverter: AnnotationBoundingBoxConverter) -> [[CGPoint]] { - guard let page = self._page else { return [] } + guard let page = _page else { return [] } let pageIndex = PageIndex(page) var paths: [[CGPoint]] = [] - for path in self.item.paths.sorted(byKeyPath: "sortIndex") { + for path in item.paths.sorted(byKeyPath: "sortIndex") { guard path.coordinates.count % 2 == 0 else { continue } let sortedCoordinates = path.coordinates.sorted(byKeyPath: "sortIndex") let lines = (0..<(path.coordinates.count / 2)).compactMap({ idx -> CGPoint? in @@ -148,31 +159,23 @@ struct PDFDatabaseAnnotation { } return paths } - - init(item: RItem) { - self.item = item - } } extension PDFDatabaseAnnotation: PDFAnnotation { var readerKey: PDFReaderState.AnnotationKey { - return .init(key: self.key, type: .database) - } - - var type: AnnotationType { - return self._type ?? .note + return .init(key: key, type: .database) } var page: Int { - return self._page ?? 0 + return _page ?? 0 } var pageLabel: String { - return self._pageLabel ?? "" + return _pageLabel ?? "" } var color: String { - return self._color ?? "#000000" + return _color ?? "#000000" } var isSyncable: Bool { @@ -182,7 +185,7 @@ extension PDFDatabaseAnnotation: PDFAnnotation { extension RItem { fileprivate func fieldValue(for key: String) -> String? { - let value = self.fields.filter(.key(key)).first?.value + let value = fields.filter(.key(key)).first?.value if value == nil { DDLogError("DatabaseAnnotation: missing value for `\(key)`") } diff --git a/Zotero/Scenes/Detail/PDF/Models/PDFDocumentAnnotation.swift b/Zotero/Scenes/Detail/PDF/Models/PDFDocumentAnnotation.swift index 3c350f3dd..bed416e9b 100644 --- a/Zotero/Scenes/Detail/PDF/Models/PDFDocumentAnnotation.swift +++ b/Zotero/Scenes/Detail/PDF/Models/PDFDocumentAnnotation.swift @@ -21,6 +21,8 @@ struct PDFDocumentAnnotation { let color: String let comment: String let text: String? + var fontSize: UInt? + var rotation: UInt? let sortIndex: String let dateModified: Date } diff --git a/Zotero/Scenes/Detail/PDF/Models/PDFReaderAction.swift b/Zotero/Scenes/Detail/PDF/Models/PDFReaderAction.swift index 558ba824a..3291bc480 100644 --- a/Zotero/Scenes/Detail/PDF/Models/PDFReaderAction.swift +++ b/Zotero/Scenes/Detail/PDF/Models/PDFReaderAction.swift @@ -29,7 +29,7 @@ enum PDFReaderAction { case setColor(key: String, color: String) case setLineWidth(key: String, width: CGFloat) case setHighlight(key: String, text: String) - case updateAnnotationProperties(key: String, color: String, lineWidth: CGFloat, pageLabel: String, updateSubsequentLabels: Bool, highlightText: String) + case updateAnnotationProperties(key: String, color: String, lineWidth: CGFloat, fontSize: UInt, pageLabel: String, updateSubsequentLabels: Bool, highlightText: String) case userInterfaceStyleChanged(UIUserInterfaceStyle) case updateAnnotationPreviews case setToolOptions(color: String?, size: CGFloat?, tool: PSPDFKit.Annotation.Tool) @@ -40,6 +40,7 @@ enum PDFReaderAction { case setComment(key: String, comment: NSAttributedString) case setCommentActive(Bool) case setVisiblePage(page: Int, userActionFromDocument: Bool, fromThumbnailList: Bool) + case setFontSize(key: String, size: UInt) case export(includeAnnotations: Bool) case clearTmpData case setSidebarEditingEnabled(Bool) diff --git a/Zotero/Scenes/Detail/PDF/Models/PDFReaderState.swift b/Zotero/Scenes/Detail/PDF/Models/PDFReaderState.swift index 8cf7f27ee..d7826d5de 100644 --- a/Zotero/Scenes/Detail/PDF/Models/PDFReaderState.swift +++ b/Zotero/Scenes/Detail/PDF/Models/PDFReaderState.swift @@ -49,6 +49,7 @@ struct PDFReaderState: ViewModelState { static let visiblePageFromDocument = Changes(rawValue: 1 << 12) static let visiblePageFromThumbnailList = Changes(rawValue: 1 << 13) static let selectionDeletion = Changes(rawValue: 1 << 14) + static let activeFontSize = Changes(rawValue: 1 << 15) } enum Error: Swift.Error { @@ -100,6 +101,7 @@ struct PDFReaderState: ViewModelState { var changedColorForTool: PSPDFKit.Annotation.Tool? var activeLineWidth: CGFloat var activeEraserSize: CGFloat + var activeFontSize: CGFloat var deletionEnabled: Bool var mergingEnabled: Bool @@ -157,10 +159,13 @@ struct PDFReaderState: ViewModelState { .highlight: UIColor(hex: Defaults.shared.highlightColorHex), .square: UIColor(hex: Defaults.shared.squareColorHex), .note: UIColor(hex: Defaults.shared.noteColorHex), - .ink: UIColor(hex: Defaults.shared.inkColorHex) + .ink: UIColor(hex: Defaults.shared.inkColorHex), + .underline: UIColor(hex: Defaults.shared.underlineColorHex), + .freeText: UIColor(hex: Defaults.shared.textColorHex) ] self.activeLineWidth = CGFloat(Defaults.shared.activeLineWidth) self.activeEraserSize = CGFloat(Defaults.shared.activeEraserSize) + self.activeFontSize = CGFloat(Defaults.shared.activeFontSize) self.deletionEnabled = false self.mergingEnabled = false self.shouldStoreAnnotationPreviewsIfNeeded = false diff --git a/Zotero/Scenes/Detail/PDF/Models/PSPDFKItAnnotation+Extensions.swift b/Zotero/Scenes/Detail/PDF/Models/PSPDFKItAnnotation+Extensions.swift new file mode 100644 index 000000000..d675fb230 --- /dev/null +++ b/Zotero/Scenes/Detail/PDF/Models/PSPDFKItAnnotation+Extensions.swift @@ -0,0 +1,119 @@ +// +// PSPDFKItAnnotation+Extensions.swift +// Zotero +// +// Created by Michal Rentka on 06.03.2024. +// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +import PSPDFKit + +extension PSPDFKit.Annotation.Tool { + var toolbarTool: AnnotationTool? { + switch self { + case .eraser: + return .eraser + + case .highlight: + return .highlight + + case .square: + return .image + + case .ink: + return .ink + + case .note: + return .note + + case .freeText: + return .freeText + + case .underline: + return .underline + + default: + return nil + } + } +} + +extension AnnotationTool { + var pspdfkitTool: PSPDFKit.Annotation.Tool { + switch self { + case .eraser: + return .eraser + + case .highlight: + return .highlight + + case .image: + return .square + + case .ink: + return .ink + + case .note: + return .note + + case .freeText: + return .freeText + + case .underline: + return .underline + } + } +} + +extension PSPDFKit.Annotation.Kind { + var annotationType: AnnotationType? { + switch self { + case .note: + return .note + + case .highlight: + return .highlight + + case .square: + return .image + + case .ink: + return .ink + + case .underline: + return .underline + + case .freeText: + return .freeText + + default: + return nil + } + } +} + +extension AnnotationType { + var kind: PSPDFKit.Annotation.Kind { + switch self { + case .note: + return .note + + case .highlight: + return .highlight + + case .image: + return .square + + case .ink: + return .ink + + case .underline: + return .underline + + case .freeText: + return .freeText + } + } +} diff --git a/Zotero/Scenes/Detail/PDF/Models/UnderlineAnnotation.swift b/Zotero/Scenes/Detail/PDF/Models/UnderlineAnnotation.swift new file mode 100644 index 000000000..6e3faee12 --- /dev/null +++ b/Zotero/Scenes/Detail/PDF/Models/UnderlineAnnotation.swift @@ -0,0 +1,35 @@ +// +// UnderlineAnnotation.swift +// Zotero +// +// Created by Michal Rentka on 18.07.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import PSPDFKit + +final class UnderlineAnnotation: PSPDFKit.UnderlineAnnotation { + override var shouldDrawNoteIconIfNeeded: Bool { + return false + } + + override func lockAndRender(in context: CGContext, options: RenderOptions?) { + super.lockAndRender(in: context, options: options) + + guard let comment = contents, !comment.isEmpty, !flags.contains(.hidden) else { return } + CommentIconDrawingController.drawAnnotationComment(context: context, boundingBox: (rects?.first ?? boundingBox), color: (color ?? .black)) + } + + override func draw(context: CGContext, options: RenderOptions?) { + super.draw(context: context, options: options) + + guard let comment = contents, !comment.isEmpty, !flags.contains(.hidden) else { return } + CommentIconDrawingController.drawAnnotationComment(context: context, boundingBox: (rects?.first ?? boundingBox), color: (color ?? .black)) + } + + override class var supportsSecureCoding: Bool { + true + } +} diff --git a/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift b/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift index f8f5903de..b594a08bc 100644 --- a/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift +++ b/Zotero/Scenes/Detail/PDF/PDFCoordinator.swift @@ -33,6 +33,9 @@ protocol PdfReaderCoordinatorDelegate: AnyObject { func showReader(document: Document, userInterfaceStyle: UIUserInterfaceStyle) func showCitation(for itemId: String, libraryId: LibraryIdentifier) func copyBibliography(using presenter: UIViewController, for itemId: String, libraryId: LibraryIdentifier) + func showFontSizePicker(sender: UIView, picked: @escaping (UInt) -> Void) + func showDeleteAlertForAnnotation(sender: UIView, delete: @escaping () -> Void) + func showTagPicker(libraryId: LibraryIdentifier, selected: Set, userInterfaceStyle: UIUserInterfaceStyle?, picked: @escaping ([Tag]) -> Void) } protocol PdfAnnotationsCoordinatorDelegate: AnyObject { @@ -348,6 +351,16 @@ extension PDFCoordinator: PdfReaderCoordinatorDelegate { self.navigationController?.present(controller, animated: true, completion: nil) } + func showDeleteAlertForAnnotation(sender: UIView, delete: @escaping () -> Void) { + let controller = UIAlertController(title: nil, message: L10n.Pdf.deleteAnnotation, preferredStyle: .actionSheet) + controller.popoverPresentationController?.sourceView = sender + controller.addAction(UIAlertAction(title: L10n.cancel, style: .cancel, handler: nil)) + controller.addAction(UIAlertAction(title: L10n.delete, style: .destructive, handler: { _ in + delete() + })) + self.navigationController?.present(controller, animated: true, completion: nil) + } + func showSettings(with settings: PDFSettings, sender: UIBarButtonItem) -> ViewModel { DDLogInfo("PDFCoordinator: show settings") @@ -387,6 +400,25 @@ extension PDFCoordinator: PdfReaderCoordinatorDelegate { func copyBibliography(using presenter: UIViewController, for itemId: String, libraryId: LibraryIdentifier) { (parentCoordinator as? DetailCoordinator)?.copyBibliography(using: presenter, for: Set([itemId]), libraryId: libraryId, delegate: self) } + + func showFontSizePicker(sender: UIView, picked: @escaping (UInt) -> Void) { + let controller = FontSizePickerViewController(pickAction: picked) + let presentedController: UIViewController + switch UIDevice.current.userInterfaceIdiom { + case .pad: + controller.modalPresentationStyle = .popover + controller.popoverPresentationController?.sourceView = sender + controller.preferredContentSize = CGSize(width: 200, height: 400) + presentedController = controller + + default: + let navigationController = UINavigationController(rootViewController: controller) + navigationController.modalPresentationStyle = .formSheet + presentedController = navigationController + } + + self.navigationController?.present(presentedController, animated: true) + } } extension PDFCoordinator: PdfAnnotationsCoordinatorDelegate { @@ -564,7 +596,8 @@ extension PDFCoordinator: PdfAnnotationsCoordinatorDelegate { color: annotation.color, lineWidth: annotation.lineWidth ?? 0, pageLabel: annotation.pageLabel, - highlightText: annotation.text ?? "" + highlightText: annotation.text ?? "", + fontSize: annotation.fontSize ), saveAction: saveAction, deleteAction: deleteAction, diff --git a/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift b/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift index 6a2a4b9e7..6e3269b8b 100644 --- a/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift +++ b/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift @@ -47,6 +47,8 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi static let lineWidth = PdfAnnotationChanges(rawValue: 1 << 3) static let paths = PdfAnnotationChanges(rawValue: 1 << 4) static let contents = PdfAnnotationChanges(rawValue: 1 << 5) + static let rotation = PdfAnnotationChanges(rawValue: 1 << 6) + static let fontSize = PdfAnnotationChanges(rawValue: 1 << 7) static func stringValues(from changes: PdfAnnotationChanges) -> [String] { var rawChanges: [String] = [] @@ -68,6 +70,12 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi if changes.contains(.contents) { rawChanges.append("contents") } + if changes.contains(.rotation) { + rawChanges.append("rotation") + } + if changes.contains(.fontSize) { + rawChanges.append("fontSize") + } return rawChanges } } @@ -172,6 +180,9 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi case .setLineWidth(let key, let width): self.set(lineWidth: width, key: key, viewModel: viewModel) + case .setFontSize(let key, let size): + self.set(fontSize: size, key: key, viewModel: viewModel) + case .setCommentActive(let isActive): guard viewModel.state.selectedAnnotationKey != nil else { return } self.update(viewModel: viewModel) { state in @@ -182,8 +193,17 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi case .setTags(let key, let tags): self.set(tags: tags, key: key, viewModel: viewModel) - case .updateAnnotationProperties(let key, let color, let lineWidth, let pageLabel, let updateSubsequentLabels, let highlightText): - self.set(color: color, lineWidth: lineWidth, pageLabel: pageLabel, updateSubsequentLabels: updateSubsequentLabels, highlightText: highlightText, key: key, viewModel: viewModel) + case .updateAnnotationProperties(let key, let color, let lineWidth, let fontSize, let pageLabel, let updateSubsequentLabels, let highlightText): + self.set( + color: color, + lineWidth: lineWidth, + fontSize: fontSize, + pageLabel: pageLabel, + updateSubsequentLabels: updateSubsequentLabels, + highlightText: highlightText, + key: key, + viewModel: viewModel + ) case .userInterfaceStyleChanged(let interfaceStyle): self.userInterfaceChanged(interfaceStyle: interfaceStyle, in: viewModel) @@ -253,7 +273,11 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi for (_, annotations) in state.document.allAnnotations(of: AnnotationsConfig.supported) { for annotation in annotations { let baseColor = annotation.baseColor - let (color, alpha, blendMode) = AnnotationColorGenerator.color(from: UIColor(hex: baseColor), isHighlight: (annotation is PSPDFKit.HighlightAnnotation), userInterfaceStyle: interfaceStyle) + let (color, alpha, blendMode) = AnnotationColorGenerator.color( + from: UIColor(hex: baseColor), + isHighlight: (annotation is PSPDFKit.HighlightAnnotation), + userInterfaceStyle: interfaceStyle + ) annotation.color = color annotation.alpha = alpha if let blendMode { @@ -272,7 +296,8 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi for (_, annotations) in viewModel.state.document.allAnnotations(of: [.square, .ink]) { for annotation in annotations { guard annotation.shouldRenderPreview && annotation.isZoteroAnnotation && - !self.annotationPreviewController.hasPreview(for: annotation.previewId, parentKey: viewModel.state.key, libraryId: libraryId, isDark: isDark) else { continue } + !self.annotationPreviewController.hasPreview(for: annotation.previewId, parentKey: viewModel.state.key, libraryId: libraryId, isDark: isDark) + else { continue } self.annotationPreviewController.store(for: annotation, parentKey: viewModel.state.key, libraryId: libraryId, isDark: isDark) } } @@ -403,7 +428,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi // rects = annotation.rects // } - case .note, .image: + case .note, .image, .underline, .freeText: return false } } @@ -541,9 +566,16 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi if !includeAnnotations { annotations = [] } else { - annotations = AnnotationConverter.annotations(from: viewModel.state.databaseAnnotations, type: .export, interfaceStyle: .light, currentUserId: viewModel.state.userId, - library: viewModel.state.library, displayName: viewModel.state.displayName, username: viewModel.state.username, - boundingBoxConverter: boundingBoxConverter) + annotations = AnnotationConverter.annotations( + from: viewModel.state.databaseAnnotations, + type: .export, + interfaceStyle: .light, + currentUserId: viewModel.state.userId, + library: viewModel.state.library, + displayName: viewModel.state.displayName, + username: viewModel.state.username, + boundingBoxConverter: boundingBoxConverter + ) } PDFDocumentExporter.export( @@ -588,6 +620,13 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi case .ink: Defaults.shared.inkColorHex = hex + + case .underline: + Defaults.shared.underlineColorHex = hex + + case .freeText: + Defaults.shared.textColorHex = hex + default: return } } @@ -599,6 +638,10 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi case .ink: Defaults.shared.activeLineWidth = Float(size) + + case .freeText: + Defaults.shared.activeFontSize = Float(size) + default: break } } @@ -618,6 +661,11 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi case .eraser: state.activeEraserSize = size state.changes = .activeEraserSize + + case .freeText: + state.activeFontSize = size + state.changes = .activeFontSize + default: break } } @@ -1019,7 +1067,8 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi /// Starts observing preview controller. If new preview is stored, it will be cached immediately. /// - parameter viewModel: ViewModel. private func observePreviews(in viewModel: ViewModel) { - self.annotationPreviewController.observable + annotationPreviewController + .observable .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self, weak viewModel] annotationKey, parentKey, image in guard let self, let viewModel, viewModel.state.key == parentKey else { return } @@ -1084,6 +1133,12 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi if annotation is PSPDFKit.InkAnnotation { return .ink } + if annotation is PSPDFKit.UnderlineAnnotation { + return .underline + } + if annotation is PSPDFKit.FreeTextAnnotation { + return .freeText + } return nil } @@ -1100,6 +1155,12 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi case .ink: return .ink + + case .underline: + return .underline + + case .freeText: + return .freeText } } @@ -1159,7 +1220,8 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi /// - parameter viewModel: ViewModel. private func remove(key: PDFReaderState.AnnotationKey, in viewModel: ViewModel) { guard let annotation = viewModel.state.annotation(for: key), - let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(annotation.page)).first(where: { $0.key == annotation.key }) else { return } + let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(annotation.page)).first(where: { $0.key == annotation.key }) + else { return } self.remove(annotations: [pdfAnnotation], in: viewModel.state.document) } @@ -1168,7 +1230,8 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi let keys = viewModel.state.selectedAnnotationsDuringEditing.filter({ $0.type == .database }) let pdfAnnotations = keys.compactMap({ key -> PSPDFKit.Annotation? in guard let annotation = viewModel.state.annotation(for: key), - let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(annotation.page)).first(where: { $0.key == annotation.key }) else { return nil } + let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(annotation.page)).first(where: { $0.key == annotation.key }) + else { return nil } return pdfAnnotation }) self.remove(annotations: pdfAnnotations, in: viewModel.state.document) @@ -1197,6 +1260,11 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi self.update(annotation: annotation, lineWidth: lineWidth, in: viewModel.state.document) } + private func set(fontSize: UInt, key: String, viewModel: ViewModel) { + guard let annotation = viewModel.state.annotation(for: PDFReaderState.AnnotationKey(key: key, type: .database)) else { return } + self.update(annotation: annotation, fontSize: fontSize, in: viewModel.state.document) + } + private func set(color: String, key: String, viewModel: ViewModel) { guard let annotation = viewModel.state.annotation(for: PDFReaderState.AnnotationKey(key: key, type: .database)) else { return } self.update(annotation: annotation, color: (color, viewModel.state.interfaceStyle), in: viewModel.state.document) @@ -1241,10 +1309,19 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi } } - private func set(color: String, lineWidth: CGFloat, pageLabel: String, updateSubsequentLabels: Bool, highlightText: String, key: String, viewModel: ViewModel) { - // `lineWidth` and `color` is stored in `Document`, update document, which will trigger a notification wich will update the DB + private func set( + color: String, + lineWidth: CGFloat, + fontSize: UInt, + pageLabel: String, + updateSubsequentLabels: Bool, + highlightText: String, + key: String, + viewModel: ViewModel + ) { + // `lineWidth`, `fontSize` and `color` is stored in `Document`, update document, which will trigger a notification wich will update the DB guard let annotation = viewModel.state.annotation(for: PDFReaderState.AnnotationKey(key: key, type: .database)) else { return } - self.update(annotation: annotation, color: (color, viewModel.state.interfaceStyle), lineWidth: lineWidth, in: viewModel.state.document) + self.update(annotation: annotation, color: (color, viewModel.state.interfaceStyle), lineWidth: lineWidth, fontSize: fontSize, in: viewModel.state.document) // Update remaining values directly let values = [KeyBaseKeyPair(key: FieldKeys.Item.Annotation.pageLabel, baseKey: nil): pageLabel, KeyBaseKeyPair(key: FieldKeys.Item.Annotation.text, baseKey: nil): highlightText] @@ -1260,7 +1337,14 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi } } - private func update(annotation: PDFAnnotation, color: (String, UIUserInterfaceStyle)? = nil, lineWidth: CGFloat? = nil, contents: String? = nil, in document: PSPDFKit.Document) { + private func update( + annotation: PDFAnnotation, + color: (String, UIUserInterfaceStyle)? = nil, + lineWidth: CGFloat? = nil, + fontSize: UInt? = nil, + contents: String? = nil, + in document: PSPDFKit.Document + ) { guard let pdfAnnotation = document.annotations(at: PageIndex(annotation.page)).first(where: { $0.key == annotation.key }) else { return } var changes: PdfAnnotationChanges = [] @@ -1268,6 +1352,9 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi if let lineWidth, lineWidth.rounded(to: 3) != annotation.lineWidth { changes.insert(.lineWidth) } + if let fontSize, fontSize != annotation.fontSize { + changes.insert(.fontSize) + } if let (color, _) = color, color != annotation.color { changes.insert(.color) } @@ -1295,6 +1382,10 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi pdfAnnotation.contents = contents } + if changes.contains(.fontSize), let textAnnotation = pdfAnnotation as? PSPDFKit.FreeTextAnnotation, let fontSize { + textAnnotation.fontSize = CGFloat(fontSize) + } + NotificationCenter.default.post( name: NSNotification.Name.PSPDFAnnotationChanged, object: pdfAnnotation, @@ -1368,6 +1459,19 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi } } else if hasChanges([.boundingBox, .rects]), let rects = AnnotationConverter.rects(from: annotation) { requests.append(EditAnnotationRectsDbRequest(key: key, libraryId: viewModel.state.library.identifier, rects: rects, boundingBoxConverter: boundingBoxConverter)) + } else if hasChanges([.boundingBox]), let rects = AnnotationConverter.rects(from: annotation) { + // FreeTextAnnotation has only `boundingBox` change, not paired with paths or rects. + requests.append(EditAnnotationRectsDbRequest(key: key, libraryId: viewModel.state.library.identifier, rects: rects, boundingBoxConverter: boundingBoxConverter)) + } + + if let textAnnotation = annotation as? PSPDFKit.FreeTextAnnotation { + if hasChanges([.rotation]) { + requests.append(EditAnnotationRotationDbRequest(key: key, libraryId: viewModel.state.library.identifier, rotation: textAnnotation.rotation)) + } + + if hasChanges([.fontSize]) { + requests.append(EditAnnotationFontSizeDbRequest(key: key, libraryId: viewModel.state.library.identifier, size: UInt(textAnnotation.fontSize))) + } } if hasChanges(.color) { @@ -1585,8 +1689,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi boundingBoxConverter: AnnotationBoundingBoxConverter, in viewModel: ViewModel ) -> (Int, (PDFReaderState.AnnotationKey, AnnotationDocumentLocation)?) { - if let key = viewModel.state.selectedAnnotationKey, let item = databaseAnnotations.filter(.key(key.key)).first { - let annotation = PDFDatabaseAnnotation(item: item) + if let key = viewModel.state.selectedAnnotationKey, let item = databaseAnnotations.filter(.key(key.key)).first, let annotation = PDFDatabaseAnnotation(item: item) { let page = annotation._page ?? storedPage let boundingBox = annotation.boundingBox(boundingBoxConverter: boundingBoxConverter) return (page, (key, (page, boundingBox))) @@ -1642,7 +1745,8 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi private func observeDocument(in viewModel: ViewModel) { NotificationCenter.default.rx .notification(.PSPDFAnnotationChanged) - .observe(on: MainScheduler.instance) + // Debounce these notifications because FreeTextAnnotation rotation change spams these annotations in milliseconds and it looks bad in sidebar while it's also unnecessary cpu burden + .debounce(.milliseconds(100), scheduler: MainScheduler.instance) .subscribe(onNext: { [weak self, weak viewModel] notification in guard let self, let viewModel else { return } self.processAnnotationObserving(notification: notification, viewModel: viewModel) @@ -1671,7 +1775,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi private func createSortedKeys(fromDatabaseAnnotations databaseAnnotations: Results, documentAnnotations: [String: PDFDocumentAnnotation]) -> [PDFReaderState.AnnotationKey] { var keys: [(PDFReaderState.AnnotationKey, String)] = [] for item in databaseAnnotations { - guard self.validate(databaseAnnotation: PDFDatabaseAnnotation(item: item)) else { continue } + guard let annotation = PDFDatabaseAnnotation(item: item), self.validate(databaseAnnotation: annotation) else { continue } keys.append((PDFReaderState.AnnotationKey(key: item.key, type: .database), item.annotationSortIndex)) } for annotation in documentAnnotations.values { @@ -1696,11 +1800,25 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi return false } - case .highlight, .image, .note: + case .highlight, .image, .note, .underline: if databaseAnnotation.item.rects.isEmpty { DDLogInfo("PDFReaderActionHandler: \(databaseAnnotation.type) annotation \(databaseAnnotation.key) missing rects") return false } + + case .freeText: + if databaseAnnotation.item.rects.isEmpty { + DDLogInfo("PDFReaderActionHandler: \(databaseAnnotation.type) annotation \(databaseAnnotation.key) missing rects") + return false + } + if databaseAnnotation.fontSize == nil { + DDLogInfo("PDFReaderActionHandler: \(databaseAnnotation.type) annotation \(databaseAnnotation.key) missing fontSize") + return false + } + if databaseAnnotation.rotation == nil { + DDLogInfo("PDFReaderActionHandler: \(databaseAnnotation.type) annotation \(databaseAnnotation.key) missing rotation") + return false + } } // Sort index consists of 3 parts separated by "|": @@ -1821,8 +1939,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi } let key = keys[index] - guard let item = objects.filter(.key(key.key)).first else { continue } - let annotation = PDFDatabaseAnnotation(item: item) + guard let item = objects.filter(.key(key.key)).first, let annotation = PDFDatabaseAnnotation(item: item) else { continue } if canUpdate(key: key, item: item, at: index, viewModel: viewModel) { DDLogInfo("PDFReaderActionHandler: update key \(key)") @@ -1835,8 +1952,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi } } - guard item.changeType == .sync, - let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(annotation.page)).first(where: { $0.key == key.key }) else { continue } + guard item.changeType == .sync, let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(annotation.page)).first(where: { $0.key == key.key }) else { continue } DDLogInfo("PDFReaderActionHandler: update PDF annotation") updatedPdfAnnotations.append((pdfAnnotation, annotation)) @@ -1860,8 +1976,9 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi selectionDeleted = true } - let oldAnnotation = PDFDatabaseAnnotation(item: viewModel.state.databaseAnnotations[index]) - guard let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(oldAnnotation.page)).first(where: { $0.key == oldAnnotation.key }) else { continue } + guard let oldAnnotation = PDFDatabaseAnnotation(item: viewModel.state.databaseAnnotations[index]), + let pdfAnnotation = viewModel.state.document.annotations(at: PageIndex(oldAnnotation.page)).first(where: { $0.key == oldAnnotation.key }) + else { continue } DDLogInfo("PDFReaderActionHandler: delete PDF annotation") deletedPdfAnnotations.append(pdfAnnotation) } @@ -1882,7 +1999,11 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi keys.insert(PDFReaderState.AnnotationKey(key: item.key, type: .database), at: index) DDLogInfo("PDFReaderActionHandler: insert key \(item.key)") - let annotation = PDFDatabaseAnnotation(item: item) + guard let annotation = PDFDatabaseAnnotation(item: item) else { + DDLogWarn("PDFReaderActionHandler: tried inserting unsupported annotation (\(item.annotationType))! keys.count=\(keys.count); index=\(index); deletions=\(deletions); insertions=\(insertions); modifications=\(modifications)") + shouldCancelUpdate = true + break + } switch item.changeType { case .user: @@ -2043,7 +2164,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi } switch annotation.type { - case .highlight: + case .highlight, .underline: let newBoundingBox = annotation.boundingBox(boundingBoxConverter: boundingBoxConverter) if newBoundingBox != pdfAnnotation.boundingBox.rounded(to: 3) { pdfAnnotation.boundingBox = newBoundingBox @@ -2079,7 +2200,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi } } - case .image: + case .image, .freeText: let newBoundingBox = annotation.boundingBox(boundingBoxConverter: boundingBoxConverter) if pdfAnnotation.boundingBox.rounded(to: 3) != newBoundingBox { changes.insert(.boundingBox) diff --git a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift index 31e30023e..cb75ffb79 100644 --- a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift +++ b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationView.swift @@ -154,23 +154,6 @@ final class AnnotationView: UIView { bottomSeparator.isHidden = (tags.isHidden && tagsButton.isHidden) || (commentTextView.isHidden && commentButtonIsHidden && highlightContentIsHidden && imageContentIsHidden) } - private func setupContent(type: AnnotationType, comment: String, text: String?, color: UIColor, canEdit: Bool, selected: Bool, availableWidth: CGFloat, accessibilityType: AccessibilityType) { - guard let highlightContent else { return } - - highlightContent.isUserInteractionEnabled = false - highlightContent.isHidden = type != .highlight - imageContent?.isHidden = true - - switch type { - case .highlight: - let bottomInset = inset(from: layout.highlightLineVerticalInsets, hasComment: !comment.isEmpty, selected: selected, canEdit: canEdit) - highlightContent.setup(with: color, text: (text ?? ""), bottomInset: bottomInset, accessibilityType: accessibilityType) - - case .image, .ink, .note: - break - } - } - private func setupContent( for annotation: PDFAnnotation, preview: UIImage?, @@ -184,17 +167,21 @@ final class AnnotationView: UIView { guard let highlightContent, let imageContent else { return } highlightContent.isUserInteractionEnabled = false - highlightContent.isHidden = annotation.type != .highlight - imageContent.isHidden = annotation.type != .image && annotation.type != .ink switch annotation.type { - case .note: break + case .note: + highlightContent.isHidden = true + imageContent.isHidden = true - case .highlight: + case .highlight, .underline: let bottomInset = inset(from: layout.highlightLineVerticalInsets, hasComment: !annotation.comment.isEmpty, selected: selected, canEdit: canEdit) + highlightContent.isHidden = false + imageContent.isHidden = true highlightContent.setup(with: color, text: (annotation.text ?? ""), bottomInset: bottomInset, accessibilityType: accessibilityType) - case .image, .ink: + case .image, .ink, .freeText: + highlightContent.isHidden = true + imageContent.isHidden = false let size = annotation.previewBoundingBox(boundingBoxConverter: boundingBoxConverter).size let maxWidth = availableWidth - (layout.horizontalInset * 2) var maxHeight = ceil((size.height / size.width) * maxWidth) diff --git a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHeader.swift b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHeader.swift index 50a914427..3e99db9d5 100644 --- a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHeader.swift +++ b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHeader.swift @@ -50,10 +50,23 @@ final class AnnotationViewHeader: UIView { private func image(for type: AnnotationType) -> UIImage? { switch type { - case .image: return Asset.Images.Annotations.areaMedium.image - case .highlight: return Asset.Images.Annotations.highlighterMedium.image - case .note: return Asset.Images.Annotations.noteMedium.image - case .ink: return Asset.Images.Annotations.inkMedium.image + case .image: + return Asset.Images.Annotations.areaMedium.image + + case .highlight: + return Asset.Images.Annotations.highlighterMedium.image + + case .note: + return Asset.Images.Annotations.noteMedium.image + + case .ink: + return Asset.Images.Annotations.inkMedium.image + + case .underline: + return UIImage(systemName: "underline") + + case .freeText: + return UIImage(systemName: "character") } } @@ -71,6 +84,12 @@ final class AnnotationViewHeader: UIView { case .ink: annotationName = L10n.Accessibility.Pdf.inkAnnotation + + case .underline: + annotationName = L10n.Accessibility.Pdf.underlineAnnotation + + case .freeText: + annotationName = L10n.Accessibility.Pdf.textAnnotation } return annotationName + ", " + L10n.page + " " + pageLabel } diff --git a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHighlightContent.swift b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHighlightContent.swift index 65b8b8a0b..13c4c0852 100644 --- a/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHighlightContent.swift +++ b/Zotero/Scenes/Detail/PDF/Views/Annotation View/AnnotationViewHighlightContent.swift @@ -41,9 +41,7 @@ final class AnnotationViewHighlightContent: UIView { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.minimumLineHeight = self.layout.lineHeight paragraphStyle.maximumLineHeight = self.layout.lineHeight - let attributedString = NSAttributedString(string: text, attributes: [.paragraphStyle: paragraphStyle, - .font: self.layout.font, - .foregroundColor: Asset.Colors.annotationText.color]) + let attributedString = NSAttributedString(string: text, attributes: [.paragraphStyle: paragraphStyle, .font: self.layout.font, .foregroundColor: Asset.Colors.annotationText.color]) self.lineView.backgroundColor = color self.textLabel.attributedText = attributedString diff --git a/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift b/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift index 252727aac..340429bf4 100644 --- a/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift +++ b/Zotero/Scenes/Detail/PDF/Views/AnnotationCell.swift @@ -170,6 +170,12 @@ final class AnnotationCell: UITableViewCell { case .ink: annotationName = L10n.Accessibility.Pdf.inkAnnotation + + case .underline: + annotationName = L10n.Accessibility.Pdf.underlineAnnotation + + case .freeText: + annotationName = L10n.Accessibility.Pdf.textAnnotation } var label = annotationName + ", " + L10n.page + " " + pageLabel if let author = author { diff --git a/Zotero/Scenes/Detail/PDF/Views/AnnotationToolOptionsViewController.swift b/Zotero/Scenes/Detail/PDF/Views/AnnotationToolOptionsViewController.swift index 11d68733f..caf2f2cd4 100644 --- a/Zotero/Scenes/Detail/PDF/Views/AnnotationToolOptionsViewController.swift +++ b/Zotero/Scenes/Detail/PDF/Views/AnnotationToolOptionsViewController.swift @@ -86,7 +86,15 @@ class AnnotationToolOptionsViewController: UIViewController { } if let size = viewModel.state.size { - let sizePicker = LineWidthView(title: L10n.size, settings: .lineWidth, contentInsets: UIEdgeInsets()) + let settings: LineWidthView.Settings + switch self.viewModel.state.tool { + case .freeText: + settings = .fontSize + + default: + settings = .lineWidth + } + let sizePicker = LineWidthView(title: L10n.size, settings: settings, contentInsets: UIEdgeInsets()) sizePicker.value = size sizePicker.valueObservable .subscribe(with: self, onNext: { `self`, value in @@ -126,6 +134,8 @@ class AnnotationToolOptionsViewController: UIViewController { case .note: return AnnotationsConfig.colors(for: .note) case .highlight: return AnnotationsConfig.colors(for: .highlight) case .image: return AnnotationsConfig.colors(for: .image) + case .freeText: return AnnotationsConfig.colors(for: .freeText) + case .underline: return AnnotationsConfig.colors(for: .underline) default: return [] } } diff --git a/Zotero/Scenes/Detail/PDF/Views/AnnotationToolbarViewController.swift b/Zotero/Scenes/Detail/PDF/Views/AnnotationToolbarViewController.swift index dfbb6fc62..3af2dab5a 100644 --- a/Zotero/Scenes/Detail/PDF/Views/AnnotationToolbarViewController.swift +++ b/Zotero/Scenes/Detail/PDF/Views/AnnotationToolbarViewController.swift @@ -153,6 +153,24 @@ class AnnotationToolbarViewController: UIViewController { image: Asset.Images.Annotations.eraserLarge.image, isHidden: false ) + + case .underline: + ToolButton( + type: .underline, + title: L10n.Pdf.AnnotationToolbar.underline, + accessibilityLabel: L10n.Accessibility.Pdf.underlineAnnotationTool, + image: UIImage(systemName: "underline", withConfiguration: UIImage.SymbolConfiguration(scale: .large))!, + isHidden: false + ) + + case .freeText: + ToolButton( + type: .freeText, + title: L10n.Pdf.AnnotationToolbar.text, + accessibilityLabel: L10n.Accessibility.Pdf.textAnnotationTool, + image: UIImage(systemName: "character", withConfiguration: UIImage.SymbolConfiguration(scale: .large))!, + isHidden: false + ) } } } @@ -256,8 +274,9 @@ class AnnotationToolbarViewController: UIViewController { let imageName: String switch tool { - case .ink, .image, .highlight, .note: + case .ink, .image, .highlight, .note, .freeText, .underline: imageName = "circle.fill" + default: imageName = "circle" } @@ -362,11 +381,16 @@ class AnnotationToolbarViewController: UIViewController { private func createHiddenToolsMenu() -> UIMenu { let children = self.toolButtons.filter({ $0.isHidden }).map({ tool in let isActive = self.delegate?.activeAnnotationTool == tool.type - return UIAction(title: tool.title, image: tool.image.withRenderingMode(.alwaysTemplate), discoverabilityTitle: tool.accessibilityLabel, state: (isActive ? .on : .off), - handler: { [weak self] _ in - guard let self = self else { return } - self.delegate?.toggle(tool: tool.type, options: self.currentAnnotationOptions) - }) + return UIAction( + title: tool.title, + image: tool.image.withRenderingMode(.alwaysTemplate), + discoverabilityTitle: tool.accessibilityLabel, + state: (isActive ? .on : .off), + handler: { [weak self] _ in + guard let self else { return } + delegate?.toggle(tool: tool.type, options: currentAnnotationOptions) + } + ) }) return UIMenu(children: children) } @@ -405,7 +429,10 @@ class AnnotationToolbarViewController: UIViewController { let recognizer = UITapGestureRecognizer() recognizer.delegate = self - recognizer.rx.event.subscribe(with: self, onNext: { `self`, _ in self.delegate?.toggle(tool: tool.type, options: self.currentAnnotationOptions) }).disposed(by: self.disposeBag) + recognizer.rx.event.subscribe(onNext: { [weak self] _ in + guard let self else { return } + delegate?.toggle(tool: tool.type, options: currentAnnotationOptions) + }).disposed(by: disposeBag) button.addGestureRecognizer(recognizer) return button diff --git a/Zotero/Scenes/Detail/PDF/Views/CustomFreeTextAnnotationView.swift b/Zotero/Scenes/Detail/PDF/Views/CustomFreeTextAnnotationView.swift new file mode 100644 index 000000000..50f63a619 --- /dev/null +++ b/Zotero/Scenes/Detail/PDF/Views/CustomFreeTextAnnotationView.swift @@ -0,0 +1,172 @@ +// +// CustomFreeTextAnnotationView.swift +// Zotero +// +// Created by Michal Rentka on 02.08.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import UIKit + +import PSPDFKit +import PSPDFKitUI +import RxSwift + +protocol FreeTextInputDelegate: AnyObject { + func showColorPicker(sender: UIView, key: PDFReaderState.AnnotationKey, updated: @escaping (String) -> Void) + func showFontSizePicker(sender: UIView, key: PDFReaderState.AnnotationKey, updated: @escaping (UInt) -> Void) + func showTagPicker(sender: UIView, key: PDFReaderState.AnnotationKey, updated: @escaping ([Tag]) -> Void) + func deleteAnnotation(sender: UIView, key: PDFReaderState.AnnotationKey) + func change(fontSize: UInt, for key: PDFReaderState.AnnotationKey) + func getColor(for key: PDFReaderState.AnnotationKey) -> UIColor? + func getFontSize(for key: PDFReaderState.AnnotationKey) -> UInt? + func getTags(for key: PDFReaderState.AnnotationKey) -> [Tag]? +} + +final class CustomFreeTextAnnotationView: FreeTextAnnotationView { + var annotationKey: PDFReaderState.AnnotationKey? + weak var delegate: FreeTextInputDelegate? + + override func textViewForEditing() -> UITextView { + let textView = super.textViewForEditing() + if let annotationKey, let delegate { + let view = FreeTextInputAccessory(key: annotationKey, delegate: delegate) + textView.inputAccessoryView = view + } + return textView + } +} + +final class FreeTextInputAccessory: UIView { + private weak var delegate: FreeTextInputDelegate? + private weak var sizePicker: FontSizeView? + private let disposeBag: DisposeBag + + init(key: PDFReaderState.AnnotationKey, delegate: FreeTextInputDelegate) { + self.delegate = delegate + self.disposeBag = DisposeBag() + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) + self.autoresizingMask = .flexibleWidth + self.backgroundColor = .systemBackground + + let separator = UIView() + separator.backgroundColor = .opaqueSeparator + let separator2 = UIView() + separator2.backgroundColor = .opaqueSeparator + let separator3 = UIView() + separator3.backgroundColor = .opaqueSeparator + + let sizePicker = FontSizeView(contentInsets: UIEdgeInsets.zero, stepperEnabled: self.traitCollection.horizontalSizeClass != .compact) + sizePicker.value = delegate.getFontSize(for: key) ?? 0 + self.sizePicker = sizePicker + sizePicker.valueObservable + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { `self`, value in + self.delegate?.change(fontSize: value, for: key) + }) + .disposed(by: self.disposeBag) + sizePicker.tapObservable + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { [weak sizePicker] `self`, _ in + guard let sizePicker else { return } + self.delegate?.showFontSizePicker(sender: sizePicker, key: key, updated: { [weak sizePicker] size in + guard let sizePicker else { return } + sizePicker.value = size + }) + }) + .disposed(by: self.disposeBag) + + let colorButton = UIButton() + var colorConfiguration = UIButton.Configuration.plain() + colorConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20) + colorConfiguration.image = UIImage(systemName: "circle.fill", withConfiguration: UIImage.SymbolConfiguration(scale: .large)) + colorButton.configuration = colorConfiguration + + let deleteButton = UIButton() + var deleteConfiguration = UIButton.Configuration.plain() + deleteConfiguration.image = UIImage(systemName: "trash", withConfiguration: UIImage.SymbolConfiguration(scale: .large)) + deleteConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20) + deleteButton.configuration = deleteConfiguration + + colorButton.tintColor = self.delegate?.getColor(for: key) ?? Asset.Colors.zoteroBlueWithDarkMode.color + colorButton.rx.tap + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { [weak colorButton] `self`, _ in + guard let colorButton else { return } + self.delegate?.showColorPicker(sender: colorButton, key: key, updated: { [weak colorButton] color in + guard let colorButton else { return } + colorButton.tintColor = UIColor(hex: color) + }) + }) + .disposed(by: self.disposeBag) + + // Can't use the Configuration API for tagButton because it ignores number of lines and just always adds multiple lines + let tagButton = UIButton() + tagButton.setAttributedTitle(self.attributedString(from: self.delegate?.getTags(for: key) ?? []), for: .normal) + tagButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + tagButton.titleLabel?.numberOfLines = 1 + tagButton.titleLabel?.lineBreakMode = .byTruncatingTail + tagButton.tintColor = Asset.Colors.zoteroBlueWithDarkMode.color + tagButton.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + tagButton.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + tagButton.rx.tap + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { [weak tagButton] `self`, _ in + guard let tagButton else { return } + self.delegate?.showTagPicker(sender: tagButton, key: key, updated: { [weak tagButton] tags in + guard let tagButton else { return } + tagButton.setAttributedTitle(self.attributedString(from: tags), for: .normal) + }) + }) + .disposed(by: self.disposeBag) + + deleteButton.tintColor = .red + deleteButton.rx.tap + .observe(on: MainScheduler.instance) + .subscribe(with: self, onNext: { [weak deleteButton] `self`, _ in + guard let deleteButton else { return } + self.delegate?.deleteAnnotation(sender: deleteButton, key: key) + }) + .disposed(by: self.disposeBag) + + let spacer = UIView() + + let container = UIStackView(arrangedSubviews: [sizePicker, spacer, separator, colorButton, separator2, tagButton, separator3, deleteButton]) + container.translatesAutoresizingMaskIntoConstraints = false + container.spacing = 0 + container.alignment = .center + self.addSubview(container) + + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(greaterThanOrEqualTo: self.leadingAnchor, constant: 20), + self.trailingAnchor.constraint(greaterThanOrEqualTo: container.trailingAnchor, constant: 20), + container.centerXAnchor.constraint(equalTo: self.centerXAnchor), + self.topAnchor.constraint(equalTo: container.topAnchor), + self.bottomAnchor.constraint(equalTo: container.bottomAnchor), + separator.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + separator.heightAnchor.constraint(equalToConstant: 30), + separator2.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + separator2.heightAnchor.constraint(equalToConstant: 30), + separator3.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + separator3.heightAnchor.constraint(equalToConstant: 30), + spacer.widthAnchor.constraint(equalToConstant: 20) + ]) + } + + private func attributedString(from tags: [Tag]) -> NSAttributedString { + if tags.isEmpty { + return NSAttributedString(string: L10n.Pdf.AnnotationsSidebar.addTags, attributes: [.foregroundColor: Asset.Colors.zoteroBlueWithDarkMode.color]) + } else { + return AttributedTagStringGenerator.attributedString(from: tags, limit: 3) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + self.sizePicker?.stepperEnabled = self.traitCollection.horizontalSizeClass != .compact + } +} diff --git a/Zotero/Scenes/Detail/PDF/Views/PDFAnnotationsViewController.swift b/Zotero/Scenes/Detail/PDF/Views/PDFAnnotationsViewController.swift index a96b99b76..4fe7b8174 100644 --- a/Zotero/Scenes/Detail/PDF/Views/PDFAnnotationsViewController.swift +++ b/Zotero/Scenes/Detail/PDF/Views/PDFAnnotationsViewController.swift @@ -125,13 +125,14 @@ final class PDFAnnotationsViewController: UIViewController { userId: self.viewModel.state.userId, library: self.viewModel.state.library, sender: sender, - userInterfaceStyle: self.viewModel.state.settings.appearanceMode.userInterfaceStyle, - saveAction: { [weak self] color, lineWidth, pageLabel, updateSubsequentLabels, highlightText in + userInterfaceStyle: self.viewModel.state.interfaceStyle, + saveAction: { [weak self] color, lineWidth, fontSize, pageLabel, updateSubsequentLabels, highlightText in self?.viewModel.process( action: .updateAnnotationProperties( key: key.key, color: color, lineWidth: lineWidth, + fontSize: fontSize, pageLabel: pageLabel, updateSubsequentLabels: updateSubsequentLabels, highlightText: highlightText @@ -288,15 +289,6 @@ final class PDFAnnotationsViewController: UIViewController { private func setup(cell: AnnotationCell, with annotation: PDFAnnotation, state: PDFReaderState) { let selected = annotation.key == state.selectedAnnotationKey?.key - - let loadPreview: () -> UIImage? = { - let preview = state.previewCache.object(forKey: (annotation.key as NSString)) - if preview == nil { - self.viewModel.process(action: .requestPreviews(keys: [annotation.key], notify: true)) - } - return preview - } - let preview: UIImage? let comment: AnnotationView.Comment? @@ -304,13 +296,13 @@ final class PDFAnnotationsViewController: UIViewController { case .image: let attributedString = parentDelegate?.parseAndCacheIfNeededAttributedComment(for: annotation) ?? NSAttributedString() comment = .init(attributedString: attributedString, isActive: state.selectedAnnotationCommentActive) - preview = loadPreview() + preview = loadPreview(for: annotation, state: state) - case .ink: + case .ink, .freeText: comment = nil - preview = loadPreview() + preview = loadPreview(for: annotation, state: state) - case .note, .highlight: + case .note, .highlight, .underline: let attributedString = parentDelegate?.parseAndCacheIfNeededAttributedComment(for: annotation) ?? NSAttributedString() comment = .init(attributedString: attributedString, isActive: state.selectedAnnotationCommentActive) preview = nil @@ -336,6 +328,14 @@ final class PDFAnnotationsViewController: UIViewController { self?.perform(action: action, annotation: annotation) }) _ = cell.disposeBag?.insert(actionSubscription) + + func loadPreview(for annotation: PDFAnnotation, state: PDFReaderState) -> UIImage? { + let preview = state.previewCache.object(forKey: (annotation.key as NSString)) + if preview == nil { + self.viewModel.process(action: .requestPreviews(keys: [annotation.key], notify: true)) + } + return preview + } } private func showFilterPopup(from barButton: UIBarButtonItem) { @@ -349,8 +349,9 @@ final class PDFAnnotationsViewController: UIViewController { } } - for annotation in self.viewModel.state.databaseAnnotations { - processAnnotation(PDFDatabaseAnnotation(item: annotation)) + for dbAnnotation in self.viewModel.state.databaseAnnotations { + guard let annotation = PDFDatabaseAnnotation(item: dbAnnotation) else { continue } + processAnnotation(annotation) } for annotation in self.viewModel.state.documentAnnotations.values { processAnnotation(annotation) @@ -607,8 +608,8 @@ extension PDFAnnotationsViewController: UITableViewDelegate, UITableViewDataSour .filter({ key in guard let annotation = self.viewModel.state.annotation(for: key) else { return false } switch annotation.type { - case .image, .ink: return true - case .note, .highlight: return false + case .image, .ink, .freeText: return true + case .note, .highlight, .underline: return false } }) .map({ $0.key }) diff --git a/Zotero/Scenes/Detail/PDF/Views/PDFDocumentViewController.swift b/Zotero/Scenes/Detail/PDF/Views/PDFDocumentViewController.swift index 703f7d023..f045679b3 100644 --- a/Zotero/Scenes/Detail/PDF/Views/PDFDocumentViewController.swift +++ b/Zotero/Scenes/Detail/PDF/Views/PDFDocumentViewController.swift @@ -12,8 +12,8 @@ import Combine import CocoaLumberjackSwift import PSPDFKit import PSPDFKitUI -import RxSwift import RealmSwift +import RxSwift protocol PDFDocumentDelegate: AnyObject { func annotationTool( @@ -38,6 +38,8 @@ final class PDFDocumentViewController: UIViewController { private static var toolHistory: [PSPDFKit.Annotation.Tool] = [] private var selectionView: SelectionView? + // Used to decide whether text annotation should start editing on tap + private var selectedAnnotationWasSelectedBefore: Bool var scrubberBarHeight: CGFloat { return self.pdfController?.userInterfaceView.scrubberBar.frame.height ?? 0 } @@ -52,6 +54,7 @@ final class PDFDocumentViewController: UIViewController { init(viewModel: ViewModel, compactSize: Bool, initialUIHidden: Bool) { self.viewModel = viewModel self.initialUIHidden = initialUIHidden + self.selectedAnnotationWasSelectedBefore = false self.disposeBag = DisposeBag() super.init(nibName: nil, bundle: nil) } @@ -172,6 +175,9 @@ final class PDFDocumentViewController: UIViewController { case .eraser: stateManager.lineWidth = self.viewModel.state.activeEraserSize + case .freeText: + stateManager.fontSize = self.viewModel.state.activeFontSize + default: break } } @@ -241,11 +247,15 @@ final class PDFDocumentViewController: UIViewController { } if state.changes.contains(.activeLineWidth) { - self.set(lineWidth: state.activeLineWidth, in: pdfController.annotationStateManager) + pdfController.annotationStateManager.lineWidth = state.activeLineWidth } if state.changes.contains(.activeEraserSize) { - self.set(lineWidth: state.activeEraserSize, in: pdfController.annotationStateManager) + pdfController.annotationStateManager.lineWidth = state.activeEraserSize + } + + if state.changes.contains(.activeFontSize) { + pdfController.annotationStateManager.fontSize = state.activeFontSize } if let notification = state.pdfNotification { @@ -320,49 +330,53 @@ final class PDFDocumentViewController: UIViewController { } private func showPopupAnnotationIfNeeded(state: PDFReaderState) { - guard !(self.parentDelegate?.isSidebarVisible ?? false), + guard !(parentDelegate?.isSidebarVisible ?? false), let annotation = state.selectedAnnotation, - let pageView = self.pdfController?.pageViewForPage(at: UInt(annotation.page)) else { return } + annotation.type != .freeText, + let pageView = pdfController?.pageViewForPage(at: UInt(annotation.page)) else { return } let key = annotation.readerKey - var frame = self.view.convert(annotation.boundingBox(boundingBoxConverter: self), from: pageView.pdfCoordinateSpace) + var frame = view.convert(annotation.boundingBox(boundingBoxConverter: self), from: pageView.pdfCoordinateSpace) frame.origin.y += parentDelegate?.documentTopOffset ?? 0 - let observable = self.coordinatorDelegate?.showAnnotationPopover( - viewModel: self.viewModel, + let observable = coordinatorDelegate?.showAnnotationPopover( + viewModel: viewModel, sourceRect: frame, popoverDelegate: self, - userInterfaceStyle: self.viewModel.state.settings.appearanceMode.userInterfaceStyle + userInterfaceStyle: viewModel.state.settings.appearanceMode.userInterfaceStyle ) guard let observable else { return } - observable.subscribe(with: self) { `self`, state in + observable.subscribe(onNext: { [weak self] state in + guard let self else { return } if state.changes.contains(.color) { - self.viewModel.process(action: .setColor(key: key.key, color: state.color)) + viewModel.process(action: .setColor(key: key.key, color: state.color)) } if state.changes.contains(.comment) { - self.viewModel.process(action: .setComment(key: key.key, comment: state.comment)) + viewModel.process(action: .setComment(key: key.key, comment: state.comment)) } if state.changes.contains(.deletion) { - self.viewModel.process(action: .removeAnnotation(key)) + viewModel.process(action: .removeAnnotation(key)) } if state.changes.contains(.lineWidth) { - self.viewModel.process(action: .setLineWidth(key: key.key, width: state.lineWidth)) + viewModel.process(action: .setLineWidth(key: key.key, width: state.lineWidth)) } if state.changes.contains(.tags) { - self.viewModel.process(action: .setTags(key: key.key, tags: state.tags)) + viewModel.process(action: .setTags(key: key.key, tags: state.tags)) } if state.changes.contains(.pageLabel) || state.changes.contains(.highlight) { - self.viewModel.process(action: + // TODO: - fix font size + viewModel.process(action: .updateAnnotationProperties( key: key.key, color: state.color, lineWidth: state.lineWidth, + fontSize: 0, pageLabel: state.pageLabel, updateSubsequentLabels: state.updateSubsequentLabels, highlightText: state.highlightText) ) } - } + }) .disposed(by: disposeBag) } @@ -403,10 +417,6 @@ final class PDFDocumentViewController: UIViewController { } } - private func set(lineWidth: CGFloat, in stateManager: AnnotationStateManager) { - stateManager.lineWidth = lineWidth - } - func setInterface(hidden: Bool) { self.pdfController?.userInterfaceView.alpha = hidden ? 0 : 1 } @@ -450,7 +460,7 @@ final class PDFDocumentViewController: UIViewController { view.removeFromSuperview() } - guard let selection = annotation, selection.type == .highlight && selection.page == Int(pageView.pageIndex) else { return } + guard let selection = annotation, (selection.type == .highlight || selection.type == .underline) && selection.page == Int(pageView.pageIndex) else { return } // Add custom highlight selection view if needed let frame = pageView.convert(selection.boundingBox(boundingBoxConverter: self), from: pageView.pdfCoordinateSpace).insetBy(dx: -SelectionView.inset, dy: -SelectionView.inset) let selectionView = SelectionView() @@ -526,12 +536,16 @@ final class PDFDocumentViewController: UIViewController { return [.highlight] } } + builder.freeTextAccessoryViewEnabled = false builder.scrubberBarType = .horizontal // builder.thumbnailBarMode = .scrubberBar builder.markupAnnotationMergeBehavior = .never + builder.freeTextAccessoryViewEnabled = false builder.overrideClass(PSPDFKit.HighlightAnnotation.self, with: HighlightAnnotation.self) builder.overrideClass(PSPDFKit.NoteAnnotation.self, with: NoteAnnotation.self) builder.overrideClass(PSPDFKit.SquareAnnotation.self, with: SquareAnnotation.self) + builder.overrideClass(PSPDFKit.UnderlineAnnotation.self, with: UnderlineAnnotation.self) + builder.overrideClass(FreeTextAnnotationView.self, with: CustomFreeTextAnnotationView.self) } let controller = PDFViewController(document: document, configuration: pdfConfiguration) @@ -622,6 +636,12 @@ extension PDFDocumentViewController: PDFViewControllerDelegate { return false } + func pdfViewController(_ pdfController: PDFViewController, shouldSelect annotations: [PSPDFKit.Annotation], on pageView: PDFPageView) -> [PSPDFKit.Annotation] { + guard let annotation = annotations.first, annotation.type == .freeText else { return annotations } + self.selectedAnnotationWasSelectedBefore = pageView.selectedAnnotations.contains(annotation) + return annotations + } + func pdfViewController( _ sender: PDFViewController, menuForAnnotations annotations: [PSPDFKit.Annotation], @@ -629,6 +649,21 @@ extension PDFDocumentViewController: PDFViewControllerDelegate { appearance: EditMenuAppearance, suggestedMenu: UIMenu ) -> UIMenu { + guard let annotation = annotations.first, + annotation.type == .freeText, + let annotationView = pageView.visibleAnnotationViews.first(where: { $0.annotation == annotation }) as? CustomFreeTextAnnotationView + else { return UIMenu(children: []) } + + annotationView.delegate = self + annotationView.annotationKey = annotation.key.flatMap({ .init(key: $0, type: .database) }) + + if annotation.key != nil && self.selectedAnnotationWasSelectedBefore { + // Focus only if Zotero annotation is selected, if annotation popup is dismissed and this annotation has been already selected + annotationView.beginEditing() + } + + self.selectedAnnotationWasSelectedBefore = false + return UIMenu(children: []) } @@ -885,6 +920,61 @@ extension PDFDocumentViewController: AnnotationBoundingBoxConverter { } } +extension PDFDocumentViewController: FreeTextInputDelegate { + func showColorPicker(sender: UIView, key: PDFReaderState.AnnotationKey, updated: @escaping (String) -> Void) { + let color = self.viewModel.state.annotation(for: key)?.color + self.coordinatorDelegate?.showToolSettings( + tool: .freeText, + colorHex: color, + sizeValue: nil, + sender: .view(sender, nil), + userInterfaceStyle: self.overrideUserInterfaceStyle, + valueChanged: { newColor, _ in + guard let newColor else { return } + self.viewModel.process(action: .setColor(key: key.key, color: newColor)) + updated(newColor) + } + ) + } + + func showFontSizePicker(sender: UIView, key: PDFReaderState.AnnotationKey, updated: @escaping (UInt) -> Void) { + self.coordinatorDelegate?.showFontSizePicker(sender: sender, picked: { [weak self] size in + self?.viewModel.process(action: .setFontSize(key: key.key, size: size)) + updated(size) + }) + } + + func showTagPicker(sender: UIView, key: PDFReaderState.AnnotationKey, updated: @escaping ([Tag]) -> Void) { + let tags = Set((self.getTags(for: key) ?? []).compactMap({ $0.name })) + self.coordinatorDelegate?.showTagPicker(libraryId: self.viewModel.state.library.identifier, selected: tags, userInterfaceStyle: self.viewModel.state.interfaceStyle, picked: { tags in + self.viewModel.process(action: .setTags(key: key.key, tags: tags)) + updated(tags) + }) + } + + func deleteAnnotation(sender: UIView, key: PDFReaderState.AnnotationKey) { + self.coordinatorDelegate?.showDeleteAlertForAnnotation(sender: sender, delete: { + self.viewModel.process(action: .removeAnnotation(key)) + }) + } + + func change(fontSize: UInt, for key: PDFReaderState.AnnotationKey) { + self.viewModel.process(action: .setFontSize(key: key.key, size: fontSize)) + } + + func getFontSize(for key: PDFReaderState.AnnotationKey) -> UInt? { + return self.viewModel.state.annotation(for: key)?.fontSize + } + + func getColor(for key: PDFReaderState.AnnotationKey) -> UIColor? { + return (self.viewModel.state.annotation(for: key)?.color).flatMap({ UIColor(hex: $0) }) + } + + func getTags(for key: PDFReaderState.AnnotationKey) -> [Tag]? { + return self.viewModel.state.annotation(for: key)?.tags + } +} + final class SelectionView: UIView { static let inset: CGFloat = 4.5 // 2.5 for border, 2 for padding diff --git a/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift b/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift index d94dd3cdf..f8f8917dd 100644 --- a/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift +++ b/Zotero/Scenes/Detail/PDF/Views/PDFReaderViewController.swift @@ -220,6 +220,7 @@ class PDFReaderViewController: UIViewController { separator.translatesAutoresizingMaskIntoConstraints = false separator.backgroundColor = Asset.Colors.annotationSidebarBorderColor.color + // TODO: Add .underline and .freeText tools when those annotations are fully available let annotationToolbar = AnnotationToolbarViewController(tools: [.highlight, .note, .image, .ink, .eraser], undoRedoEnabled: true, size: navigationBarHeight) annotationToolbar.delegate = self @@ -511,6 +512,9 @@ class PDFReaderViewController: UIViewController { case .eraser: size = Float(viewModel.state.activeEraserSize) + case .freeText: + size = Float(self.viewModel.state.activeFontSize) + default: size = nil } @@ -907,48 +911,3 @@ extension PDFReaderViewController: PDFSearchDelegate { documentController.highlightSelectedSearchResult(result) } } - -extension PSPDFKit.Annotation.Tool { - fileprivate var toolbarTool: AnnotationTool? { - switch self { - case .eraser: - return .eraser - - case .highlight: - return .highlight - - case .square: - return .image - - case .ink: - return .ink - - case .note: - return .note - - default: - return nil - } - } -} - -extension AnnotationTool { - fileprivate var pspdfkitTool: PSPDFKit.Annotation.Tool { - switch self { - case .eraser: - return .eraser - - case .highlight: - return .highlight - - case .image: - return .square - - case .ink: - return .ink - - case .note: - return .note - } - } -}