diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..8909bb63b --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,10 @@ +### Expected behavior + + +### Actual behavior + + +### Steps to reproduce the behavior + + +##### Tested on [device], iOS [version], Aztec [version] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..0977562c4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,4 @@ +Fixes # + +To test: + diff --git a/Aztec.xcodeproj/project.pbxproj b/Aztec.xcodeproj/project.pbxproj index 5fade849c..82985dea3 100644 --- a/Aztec.xcodeproj/project.pbxproj +++ b/Aztec.xcodeproj/project.pbxproj @@ -10,8 +10,6 @@ 594C9D6D1D8BE57600D74542 /* Gridicons.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 599F255B1D8BCDB4002871D6 /* Gridicons.framework */; }; 594C9D6F1D8BE61F00D74542 /* Aztec.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 5951CB8E1D8BC93600E1866F /* Aztec.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 594C9D701D8BE62500D74542 /* Gridicons.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 599F255B1D8BCDB4002871D6 /* Gridicons.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 594C9D711D8BE6B800D74542 /* OutAttributeConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA05E1D8BDFA700D138DF /* OutAttributeConverterTests.swift */; }; - 594C9D721D8BE6BC00D74542 /* OutNodeConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA0601D8BDFA700D138DF /* OutNodeConverterTests.swift */; }; 594C9D731D8BE6C300D74542 /* InAttributeConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06B1D8BDFA700D138DF /* InAttributeConverterTests.swift */; }; 594C9D741D8BE6C700D74542 /* InNodeConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06D1D8BDFA700D138DF /* InNodeConverterTests.swift */; }; 5951CB981D8BC93600E1866F /* Aztec.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5951CB8E1D8BC93600E1866F /* Aztec.framework */; }; @@ -23,9 +21,6 @@ 599F25391D8BC9A1002871D6 /* InHTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F250C1D8BC9A1002871D6 /* InHTMLConverter.swift */; }; 599F253A1D8BC9A1002871D6 /* InNodeConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F250D1D8BC9A1002871D6 /* InNodeConverter.swift */; }; 599F253B1D8BC9A1002871D6 /* InNodesConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F250E1D8BC9A1002871D6 /* InNodesConverter.swift */; }; - 599F253C1D8BC9A1002871D6 /* OutHTMLAttributeConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25101D8BC9A1002871D6 /* OutHTMLAttributeConverter.swift */; }; - 599F253D1D8BC9A1002871D6 /* OutHTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25111D8BC9A1002871D6 /* OutHTMLConverter.swift */; }; - 599F253E1D8BC9A1002871D6 /* OutHTMLNodeConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25121D8BC9A1002871D6 /* OutHTMLNodeConverter.swift */; }; 599F25451D8BC9A1002871D6 /* Libxml2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F251A1D8BC9A1002871D6 /* Libxml2.swift */; }; 599F25471D8BC9A1002871D6 /* HTMLConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25201D8BC9A1002871D6 /* HTMLConstants.swift */; }; 599F25481D8BC9A1002871D6 /* Metrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25211D8BC9A1002871D6 /* Metrics.swift */; }; @@ -36,80 +31,119 @@ 599F254E1D8BC9A1002871D6 /* FormatBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F252B1D8BC9A1002871D6 /* FormatBarDelegate.swift */; }; 599F254F1D8BC9A1002871D6 /* FormatBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F252C1D8BC9A1002871D6 /* FormatBarItem.swift */; }; 599F25501D8BC9A1002871D6 /* FormattingIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F252D1D8BC9A1002871D6 /* FormattingIdentifier.swift */; }; - 599F25521D8BC9A1002871D6 /* TextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25301D8BC9A1002871D6 /* TextAttachment.swift */; }; + 599F25521D8BC9A1002871D6 /* MediaAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25301D8BC9A1002871D6 /* MediaAttachment.swift */; }; 599F25531D8BC9A1002871D6 /* TextStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25311D8BC9A1002871D6 /* TextStorage.swift */; }; 599F25541D8BC9A1002871D6 /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25321D8BC9A1002871D6 /* TextView.swift */; }; 599F255C1D8BCDB4002871D6 /* Gridicons.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 599F255B1D8BCDB4002871D6 /* Gridicons.framework */; }; 599F25941D8BDCFC002871D6 /* TextStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25921D8BDCFC002871D6 /* TextStorageTests.swift */; }; 599F25951D8BDCFC002871D6 /* TextViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599F25931D8BDCFC002871D6 /* TextViewTests.swift */; }; - 59FEA06F1D8BDFA700D138DF /* OutHTMLConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA05F1D8BDFA700D138DF /* OutHTMLConverterTests.swift */; }; 59FEA0741D8BDFA700D138DF /* ElementNodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA0651D8BDFA700D138DF /* ElementNodeTests.swift */; }; 59FEA0751D8BDFA700D138DF /* NodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA0661D8BDFA700D138DF /* NodeTests.swift */; }; 59FEA0781D8BDFA700D138DF /* HTMLToAttributedStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06A1D8BDFA700D138DF /* HTMLToAttributedStringTests.swift */; }; 59FEA07A1D8BDFA700D138DF /* InHTMLConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06C1D8BDFA700D138DF /* InHTMLConverterTests.swift */; }; + B50CE7321F1FA6260018CAA1 /* NSAttributedString+Strip.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50CE7311F1FA6260018CAA1 /* NSAttributedString+Strip.swift */; }; + B50CE7341F1FABA00018CAA1 /* content.html in Resources */ = {isa = PBXBuildFile; fileRef = B50CE7331F1FABA00018CAA1 /* content.html */; }; + B5375F471EC2566200F5D7EC /* String+HTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5375F461EC2566200F5D7EC /* String+HTML.swift */; }; + B5375F491EC2569500F5D7EC /* StringHTMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5375F481EC2569500F5D7EC /* StringHTMLTests.swift */; }; + B542D6421E9EB122009D12D3 /* PreFormaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B542D6411E9EB122009D12D3 /* PreFormaterTests.swift */; }; B551A4A01E770B3800EE3A7F /* UIFont+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = B551A49F1E770B3800EE3A7F /* UIFont+Emoji.swift */; }; + B5657C151EE99A8600579FE1 /* NSAttributedStringToNodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5657C141EE99A8600579FE1 /* NSAttributedStringToNodes.swift */; }; + B5657C181EE99AF500579FE1 /* NSAttributedStringToNodesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5657C171EE99AF500579FE1 /* NSAttributedStringToNodesTests.swift */; }; B572AC281E817CFE008948C2 /* CommentAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B572AC271E817CFE008948C2 /* CommentAttachment.swift */; }; B577DC651E7B18E90012A1F8 /* NodeDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B577DC641E7B18E90012A1F8 /* NodeDescriptor.swift */; }; B577DC671E7B18F80012A1F8 /* CommentNodeDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B577DC661E7B18F80012A1F8 /* CommentNodeDescriptor.swift */; }; + B57D1C3D1E92C38000EA4B16 /* HTMLAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57D1C3C1E92C38000EA4B16 /* HTMLAttachment.swift */; }; B59C9F9F1DF74BB80073B1D6 /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59C9F9E1DF74BB80073B1D6 /* UIFont+Traits.swift */; }; + B5A5277C1EF1B44800E7D2FD /* UnsupportedHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5277B1EF1B44800E7D2FD /* UnsupportedHTML.swift */; }; + B5A5277E1EF1BAC000E7D2FD /* HTMLNodeToNSAttributedStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5277D1EF1BAC000E7D2FD /* HTMLNodeToNSAttributedStringTests.swift */; }; + B5A99D841EBA073D00DED081 /* HTMLStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A99D831EBA073D00DED081 /* HTMLStorage.swift */; }; + B5AF89321E93CFC80051EFDB /* RenderableAttachmentDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AF89311E93CFC80051EFDB /* RenderableAttachmentDelegate.swift */; }; B5B86D371DA3EC250083DB3F /* NSRange+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18733C41DA096EE005AEB80 /* NSRange+Helpers.swift */; }; - B5B86D3C1DA41A550083DB3F /* TextListFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B86D3B1DA41A550083DB3F /* TextListFormatter.swift */; }; B5B96DAB1E01B2F300791315 /* UIPasteboard+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B96DAA1E01B2F300791315 /* UIPasteboard+Helpers.swift */; }; B5BC4FEE1DA2C17800614582 /* NSAttributedString+Lists.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BC4FED1DA2C17800614582 /* NSAttributedString+Lists.swift */; }; B5BC4FF21DA2D17000614582 /* NSAttributedStringListsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BC4FF11DA2D17000614582 /* NSAttributedStringListsTests.swift */; }; - B5BC4FF61DA2D76600614582 /* TextList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BC4FF51DA2D76600614582 /* TextList.swift */; }; B5C99D3F1E72E2E700335355 /* UIStackView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C99D3E1E72E2E700335355 /* UIStackView+Helpers.swift */; }; + B5DA1C1D1EBD1CCE000AAB45 /* OutHTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DA1C1C1EBD1CCE000AAB45 /* OutHTMLConverter.swift */; }; + B5DA1C1F1EBD1EA7000AAB45 /* OutHTMLConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DA1C1E1EBD1EA7000AAB45 /* OutHTMLConverterTests.swift */; }; B5E607331DA56EC700C8A389 /* TextListFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E607321DA56EC700C8A389 /* TextListFormatterTests.swift */; }; B5F84B611E70595B0089A76C /* NSAttributedString+Analyzers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F84B601E70595B0089A76C /* NSAttributedString+Analyzers.swift */; }; B5F84B631E706B720089A76C /* NSAttributedStringAnalyzerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F84B621E706B720089A76C /* NSAttributedStringAnalyzerTests.swift */; }; E109B51C1DC33F2C0099605E /* LayoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E109B51B1DC33F2C0099605E /* LayoutManager.swift */; }; E11B77601DBA14B40024E455 /* BlockquoteFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B775F1DBA14B40024E455 /* BlockquoteFormatterTests.swift */; }; - E11B77641DBA6ADC0024E455 /* AttributeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B77631DBA6ADC0024E455 /* AttributeFormatter.swift */; }; - E1C163A51DB6056B00E66A83 /* BlockquoteFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C163A41DB6056B00E66A83 /* BlockquoteFormatter.swift */; }; - F111DF0F1E3BA393003FB794 /* NSAttributedString+AttributeRanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = F111DF0E1E3BA393003FB794 /* NSAttributedString+AttributeRanges.swift */; }; - F111DF111E3BAC8E003FB794 /* NSAttributedStringAttributeRangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F111DF101E3BAC8E003FB794 /* NSAttributedStringAttributeRangesTests.swift */; }; + F1000CE71EAA44AA0000B15B /* String+EndOfLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000CE61EAA44AA0000B15B /* String+EndOfLine.swift */; }; + F1000CE91EAA5C720000B15B /* StringEndOfLineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000CE81EAA5C720000B15B /* StringEndOfLineTests.swift */; }; + F10BE6181EA7ADA6002E4625 /* NSMutableAttributedString+ReplaceOcurrences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10BE6171EA7ADA6002E4625 /* NSMutableAttributedString+ReplaceOcurrences.swift */; }; + F10BE61A1EA7AE9D002E4625 /* NSAttributedString+ReplaceOcurrences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10BE6191EA7AE9D002E4625 /* NSAttributedString+ReplaceOcurrences.swift */; }; + F10BE61C1EA7B1DB002E4625 /* NSAttributedStringReplaceOcurrencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10BE61B1EA7B1DB002E4625 /* NSAttributedStringReplaceOcurrencesTests.swift */; }; + F11326AF1EF1AA91007FEE9A /* Blockquote.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11326A91EF1AA91007FEE9A /* Blockquote.swift */; }; + F11326B01EF1AA91007FEE9A /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11326AA1EF1AA91007FEE9A /* Header.swift */; }; + F11326B11EF1AA91007FEE9A /* HTMLParagraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11326AB1EF1AA91007FEE9A /* HTMLParagraph.swift */; }; + F11326B21EF1AA91007FEE9A /* HTMLPre.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11326AC1EF1AA91007FEE9A /* HTMLPre.swift */; }; + F11326B31EF1AA91007FEE9A /* ParagraphProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11326AD1EF1AA91007FEE9A /* ParagraphProperty.swift */; }; + F11326B41EF1AA91007FEE9A /* TextList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11326AE1EF1AA91007FEE9A /* TextList.swift */; }; F11904A51D9D857500BFF9A1 /* TextNodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11904A41D9D857500BFF9A1 /* TextNodeTests.swift */; }; F1288BAF1DD0B1EF00E67ABC /* HTMLNodeToNSAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1288BAD1DD0B1EF00E67ABC /* HTMLNodeToNSAttributedString.swift */; }; F1288BB01DD0B1EF00E67ABC /* HTMLToAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1288BAE1DD0B1EF00E67ABC /* HTMLToAttributedString.swift */; }; + F12F58631EF20394008AE298 /* AttributeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58511EF20394008AE298 /* AttributeFormatter.swift */; }; + F12F58651EF20394008AE298 /* ParagraphAttributeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58531EF20394008AE298 /* ParagraphAttributeFormatter.swift */; }; + F12F58661EF20394008AE298 /* StandardAttributeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58541EF20394008AE298 /* StandardAttributeFormatter.swift */; }; + F12F58671EF20394008AE298 /* BlockquoteFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58561EF20394008AE298 /* BlockquoteFormatter.swift */; }; + F12F58681EF20394008AE298 /* ColorFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58571EF20394008AE298 /* ColorFormatter.swift */; }; + F12F586A1EF20394008AE298 /* HeaderFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58591EF20394008AE298 /* HeaderFormatter.swift */; }; + F12F586B1EF20394008AE298 /* HRFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F585A1EF20394008AE298 /* HRFormatter.swift */; }; + F12F586C1EF20394008AE298 /* HTMLParagraphFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F585B1EF20394008AE298 /* HTMLParagraphFormatter.swift */; }; + F12F586D1EF20394008AE298 /* ImageFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F585C1EF20394008AE298 /* ImageFormatter.swift */; }; + F12F586E1EF20394008AE298 /* LinkFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F585D1EF20394008AE298 /* LinkFormatter.swift */; }; + F12F586F1EF20394008AE298 /* PreFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F585E1EF20394008AE298 /* PreFormatter.swift */; }; + F12F58701EF20394008AE298 /* StrikethroughFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F585F1EF20394008AE298 /* StrikethroughFormatter.swift */; }; + F12F58711EF20394008AE298 /* TextListFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58601EF20394008AE298 /* TextListFormatter.swift */; }; + F12F58721EF20394008AE298 /* UnderlineFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58611EF20394008AE298 /* UnderlineFormatter.swift */; }; + F12F58731EF20394008AE298 /* VideoFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12F58621EF20394008AE298 /* VideoFormatter.swift */; }; F138062A1E3651DC00CFB9ED /* ControlCharacters.rtf in Resources */ = {isa = PBXBuildFile; fileRef = F13806291E3651DC00CFB9ED /* ControlCharacters.rtf */; }; + F14665451EA7C230008DE2B8 /* NSMutableAttributedStringReplaceOcurrencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F14665441EA7C230008DE2B8 /* NSMutableAttributedStringReplaceOcurrencesTests.swift */; }; F15C9B881DD58D8B00833C39 /* ElementNodeDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15C9B871DD58D8B00833C39 /* ElementNodeDescriptor.swift */; }; - F15F61C91E0323EC00CD6DD8 /* EditContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15F61C81E0323EC00CD6DD8 /* EditContext.swift */; }; - F17D64AE1E4230A400D09FED /* VisualOnlyAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17D64AD1E4230A400D09FED /* VisualOnlyAttribute.swift */; }; - F17D64B01E4231C800D09FED /* VisualOnlyElementFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17D64AF1E4231C800D09FED /* VisualOnlyElementFactory.swift */; }; + F16DD2CB1EE99B850083A098 /* HTMLRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16DD2CA1EE99B850083A098 /* HTMLRepresentation.swift */; }; + F16DD2D31EE99E720083A098 /* HTMLElementRepresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16DD2D21EE99E720083A098 /* HTMLElementRepresentationTests.swift */; }; + F16DD2D51EE99E820083A098 /* HTMLElementRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16DD2D41EE99E820083A098 /* HTMLElementRepresentation.swift */; }; + F16DD2D71EE99EA20083A098 /* HTMLAttributeRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16DD2D61EE99EA20083A098 /* HTMLAttributeRepresentation.swift */; }; F181CB381E52650F00B256C8 /* NSAttributedString+AttributeDifferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F181CB371E52650F00B256C8 /* NSAttributedString+AttributeDifferences.swift */; }; F18733C81DA09737005AEB80 /* NSRangeComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18733C71DA09737005AEB80 /* NSRangeComparisonTests.swift */; }; + F18986E11EF2040A0060EDBA /* FontFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18986E01EF2040A0060EDBA /* FontFormatter.swift */; }; + F18986E31EF204180060EDBA /* BoldFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18986E21EF204180060EDBA /* BoldFormatter.swift */; }; + F18986E51EF2043E0060EDBA /* ItalicFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18986E41EF2043E0060EDBA /* ItalicFormatter.swift */; }; + F18B81EB1EA5601000885F43 /* StringUnicodeScalarView+RangeConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18B81EA1EA5601000885F43 /* StringUnicodeScalarView+RangeConversion.swift */; }; + F18B81ED1EA560B700885F43 /* StringUTF16+RangeConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18B81EC1EA560B700885F43 /* StringUTF16+RangeConversion.swift */; }; F1A218151E02D5B3000AF5EB /* UndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A218141E02D5B3000AF5EB /* UndoManager.swift */; }; F1C05B991E37F99D007510EA /* Character+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C05B981E37F99D007510EA /* Character+Name.swift */; }; F1C05B9D1E37FA77007510EA /* String+CharacterName.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C05B9C1E37FA77007510EA /* String+CharacterName.swift */; }; F1C05B9F1E37FD2F007510EA /* NSAttributedString+CharacterName.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C05B9E1E37FD2F007510EA /* NSAttributedString+CharacterName.swift */; }; - F1CF272A1DBA8CDB0001C61D /* DOMString.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1CF27291DBA8CDB0001C61D /* DOMString.swift */; }; - F1DB0B511E6EF5E600FB4C75 /* DOMInspectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DB0B501E6EF5E600FB4C75 /* DOMInspectorTests.swift */; }; - F1DB0B531E6EF8AE00FB4C75 /* DOMEditorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DB0B521E6EF8AE00FB4C75 /* DOMEditorTests.swift */; }; - F1FA0E731E6EF25A009D98EE /* DOMEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E721E6EF25A009D98EE /* DOMEditor.swift */; }; - F1FA0E751E6EF267009D98EE /* DOMLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E741E6EF267009D98EE /* DOMLogic.swift */; }; - F1FA0E771E6EF29B009D98EE /* DOMInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E761E6EF29B009D98EE /* DOMInspector.swift */; }; + F1C55D9E1EE9A976002CE99D /* HTMLAttributeRepresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C55D9D1EE9A976002CE99D /* HTMLAttributeRepresentationTests.swift */; }; + F1DE83D51EF20493009269E6 /* UIColor+Parsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DE83D41EF20493009269E6 /* UIColor+Parsers.swift */; }; F1FA0E811E6EF514009D98EE /* Attribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E791E6EF514009D98EE /* Attribute.swift */; }; F1FA0E821E6EF514009D98EE /* CommentNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E7A1E6EF514009D98EE /* CommentNode.swift */; }; F1FA0E831E6EF514009D98EE /* ElementNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E7B1E6EF514009D98EE /* ElementNode.swift */; }; - F1FA0E841E6EF514009D98EE /* LeafNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E7C1E6EF514009D98EE /* LeafNode.swift */; }; F1FA0E851E6EF514009D98EE /* MarkerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E7D1E6EF514009D98EE /* MarkerNode.swift */; }; F1FA0E861E6EF514009D98EE /* Node.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E7E1E6EF514009D98EE /* Node.swift */; }; F1FA0E871E6EF514009D98EE /* StandardElementType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E7F1E6EF514009D98EE /* StandardElementType.swift */; }; F1FA0E881E6EF514009D98EE /* TextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FA0E801E6EF514009D98EE /* TextNode.swift */; }; - F1FFB2A11E6058930015ACB8 /* DOMStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FFB2A01E6058930015ACB8 /* DOMStringTests.swift */; }; - FF13CD4D1E5C8067000FF10E /* HeaderFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF13CD4C1E5C8067000FF10E /* HeaderFormatter.swift */; }; + FF0714021EFD78AF00E50713 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF0714001EFD78AF00E50713 /* Media.xcassets */; }; FF152D8E1E68552A00FF596C /* StringRangeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF152D8D1E68552A00FF596C /* StringRangeConversionTests.swift */; }; + FF20D6401EDC389A00294B78 /* HTMLAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF20D63D1EDC389A00294B78 /* HTMLAttributes.swift */; }; + FF20D6411EDC389A00294B78 /* HTMLProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF20D63E1EDC389A00294B78 /* HTMLProcessor.swift */; }; + FF20D6421EDC389A00294B78 /* Processor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF20D63F1EDC389A00294B78 /* Processor.swift */; }; + FF20D6441EDC395E00294B78 /* NSTextingResult+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF20D6431EDC395E00294B78 /* NSTextingResult+Helpers.swift */; }; + FF20D6471EDC3B3900294B78 /* HTMLProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF20D6461EDC3B3900294B78 /* HTMLProcessorTests.swift */; }; + FF24AC991F0146AF003CA91D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF24AC981F0146AF003CA91D /* Assets.swift */; }; + FF4E26601EA8DF1E005E8E42 /* ImageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4E265F1EA8DF1E005E8E42 /* ImageAttachment.swift */; }; FF7A1C511E5651EA00C4C7C8 /* LineAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7A1C501E5651EA00C4C7C8 /* LineAttachment.swift */; }; - FF7C89A81E3A2B7C000472A8 /* Blockquote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7C89A71E3A2B7C000472A8 /* Blockquote.swift */; }; - FF7C89AC1E3A47F1000472A8 /* StandardAttributeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7C89AB1E3A47F1000472A8 /* StandardAttributeFormatter.swift */; }; FF7C89B01E3BC52F000472A8 /* NSAttributedString+FontTraits.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7C89AF1E3BC52F000472A8 /* NSAttributedString+FontTraits.swift */; }; - FF7DCB471E80837D00AB77CB /* UIColor+Parsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCB461E80837D00AB77CB /* UIColor+Parsers.swift */; }; FF7DCB4B1E815F9400AB77CB /* UIColorHexParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCB4A1E815F9400AB77CB /* UIColorHexParserTests.swift */; }; + FF8669EC1EE7677F00F071A6 /* TextViewStubAttachmentDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8669EB1EE7677F00F071A6 /* TextViewStubAttachmentDelegate.swift */; }; FFA61E891DF18F3D00B71BF6 /* ParagraphStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA61E881DF18F3D00B71BF6 /* ParagraphStyle.swift */; }; FFA61EC21DF6C1C900B71BF6 /* NSAttributedString+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA61EC11DF6C1C900B71BF6 /* NSAttributedString+Archive.swift */; }; FFD0FEB71DAE59A700430586 /* NSLayoutManager+Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD0FEB61DAE59A700430586 /* NSLayoutManager+Attachments.swift */; }; - FFD436961E300EF800A0E26F /* FontFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD436951E300EF700A0E26F /* FontFormatter.swift */; }; FFD436981E3180A500A0E26F /* FontFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD436971E3180A500A0E26F /* FontFormatterTests.swift */; }; + FFFEC7DB1EA7698900F4210F /* VideoAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFEC7DA1EA7698900F4210F /* VideoAttachment.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -149,9 +183,6 @@ 599F250C1D8BC9A1002871D6 /* InHTMLConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InHTMLConverter.swift; sourceTree = ""; }; 599F250D1D8BC9A1002871D6 /* InNodeConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InNodeConverter.swift; sourceTree = ""; }; 599F250E1D8BC9A1002871D6 /* InNodesConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InNodesConverter.swift; sourceTree = ""; }; - 599F25101D8BC9A1002871D6 /* OutHTMLAttributeConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutHTMLAttributeConverter.swift; sourceTree = ""; }; - 599F25111D8BC9A1002871D6 /* OutHTMLConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutHTMLConverter.swift; sourceTree = ""; }; - 599F25121D8BC9A1002871D6 /* OutHTMLNodeConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutHTMLNodeConverter.swift; sourceTree = ""; }; 599F251A1D8BC9A1002871D6 /* Libxml2.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Libxml2.swift; sourceTree = ""; }; 599F25201D8BC9A1002871D6 /* HTMLConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLConstants.swift; sourceTree = ""; }; 599F25211D8BC9A1002871D6 /* Metrics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Metrics.swift; sourceTree = ""; }; @@ -162,7 +193,7 @@ 599F252B1D8BC9A1002871D6 /* FormatBarDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FormatBarDelegate.swift; sourceTree = ""; }; 599F252C1D8BC9A1002871D6 /* FormatBarItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FormatBarItem.swift; sourceTree = ""; }; 599F252D1D8BC9A1002871D6 /* FormattingIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FormattingIdentifier.swift; sourceTree = ""; }; - 599F25301D8BC9A1002871D6 /* TextAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextAttachment.swift; sourceTree = ""; }; + 599F25301D8BC9A1002871D6 /* MediaAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAttachment.swift; sourceTree = ""; }; 599F25311D8BC9A1002871D6 /* TextStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextStorage.swift; sourceTree = ""; }; 599F25321D8BC9A1002871D6 /* TextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; 599F25571D8BCA01002871D6 /* libxml2-umbrella.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "libxml2-umbrella.h"; sourceTree = ""; }; @@ -172,82 +203,119 @@ 599F25901D8BDCA9002871D6 /* UnsupportedTags.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; name = UnsupportedTags.rtf; path = ../Documentation/UnsupportedTags.rtf; sourceTree = ""; }; 599F25921D8BDCFC002871D6 /* TextStorageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextStorageTests.swift; sourceTree = ""; }; 599F25931D8BDCFC002871D6 /* TextViewTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextViewTests.swift; sourceTree = ""; }; - 59FEA05E1D8BDFA700D138DF /* OutAttributeConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutAttributeConverterTests.swift; sourceTree = ""; }; - 59FEA05F1D8BDFA700D138DF /* OutHTMLConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutHTMLConverterTests.swift; sourceTree = ""; }; - 59FEA0601D8BDFA700D138DF /* OutNodeConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutNodeConverterTests.swift; sourceTree = ""; }; 59FEA0651D8BDFA700D138DF /* ElementNodeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ElementNodeTests.swift; sourceTree = ""; }; 59FEA0661D8BDFA700D138DF /* NodeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NodeTests.swift; sourceTree = ""; }; 59FEA06A1D8BDFA700D138DF /* HTMLToAttributedStringTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLToAttributedStringTests.swift; sourceTree = ""; }; 59FEA06B1D8BDFA700D138DF /* InAttributeConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAttributeConverterTests.swift; sourceTree = ""; }; 59FEA06C1D8BDFA700D138DF /* InHTMLConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InHTMLConverterTests.swift; sourceTree = ""; }; 59FEA06D1D8BDFA700D138DF /* InNodeConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InNodeConverterTests.swift; sourceTree = ""; }; + B50CE7311F1FA6260018CAA1 /* NSAttributedString+Strip.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Strip.swift"; sourceTree = ""; }; + B50CE7331F1FABA00018CAA1 /* content.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = content.html; path = Example/Example/SampleContent/content.html; sourceTree = SOURCE_ROOT; }; + B5375F461EC2566200F5D7EC /* String+HTML.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+HTML.swift"; sourceTree = ""; }; + B5375F481EC2569500F5D7EC /* StringHTMLTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = StringHTMLTests.swift; path = Extensions/StringHTMLTests.swift; sourceTree = ""; }; + B542D6411E9EB122009D12D3 /* PreFormaterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreFormaterTests.swift; sourceTree = ""; }; B551A49F1E770B3800EE3A7F /* UIFont+Emoji.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIFont+Emoji.swift"; sourceTree = ""; }; + B5657C141EE99A8600579FE1 /* NSAttributedStringToNodes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSAttributedStringToNodes.swift; sourceTree = ""; }; + B5657C171EE99AF500579FE1 /* NSAttributedStringToNodesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSAttributedStringToNodesTests.swift; sourceTree = ""; }; B572AC271E817CFE008948C2 /* CommentAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentAttachment.swift; sourceTree = ""; }; B577DC641E7B18E90012A1F8 /* NodeDescriptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NodeDescriptor.swift; path = Descriptors/NodeDescriptor.swift; sourceTree = ""; }; B577DC661E7B18F80012A1F8 /* CommentNodeDescriptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CommentNodeDescriptor.swift; path = Descriptors/CommentNodeDescriptor.swift; sourceTree = ""; }; + B57D1C3C1E92C38000EA4B16 /* HTMLAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLAttachment.swift; sourceTree = ""; }; B59C9F9E1DF74BB80073B1D6 /* UIFont+Traits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; }; - B5B86D3B1DA41A550083DB3F /* TextListFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextListFormatter.swift; sourceTree = ""; }; + B5A5277B1EF1B44800E7D2FD /* UnsupportedHTML.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsupportedHTML.swift; sourceTree = ""; }; + B5A5277D1EF1BAC000E7D2FD /* HTMLNodeToNSAttributedStringTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLNodeToNSAttributedStringTests.swift; sourceTree = ""; }; + B5A99D831EBA073D00DED081 /* HTMLStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLStorage.swift; sourceTree = ""; }; + B5AF89311E93CFC80051EFDB /* RenderableAttachmentDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RenderableAttachmentDelegate.swift; sourceTree = ""; }; B5B96DAA1E01B2F300791315 /* UIPasteboard+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIPasteboard+Helpers.swift"; sourceTree = ""; }; B5BC4FED1DA2C17800614582 /* NSAttributedString+Lists.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Lists.swift"; sourceTree = ""; }; B5BC4FF11DA2D17000614582 /* NSAttributedStringListsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NSAttributedStringListsTests.swift; path = Extensions/NSAttributedStringListsTests.swift; sourceTree = ""; }; - B5BC4FF51DA2D76600614582 /* TextList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextList.swift; sourceTree = ""; }; B5C99D3E1E72E2E700335355 /* UIStackView+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIStackView+Helpers.swift"; sourceTree = ""; }; + B5DA1C1C1EBD1CCE000AAB45 /* OutHTMLConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutHTMLConverter.swift; sourceTree = ""; }; + B5DA1C1E1EBD1EA7000AAB45 /* OutHTMLConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutHTMLConverterTests.swift; sourceTree = ""; }; B5E607321DA56EC700C8A389 /* TextListFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextListFormatterTests.swift; sourceTree = ""; }; B5F84B601E70595B0089A76C /* NSAttributedString+Analyzers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Analyzers.swift"; sourceTree = ""; }; B5F84B621E706B720089A76C /* NSAttributedStringAnalyzerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NSAttributedStringAnalyzerTests.swift; path = Extensions/NSAttributedStringAnalyzerTests.swift; sourceTree = ""; }; E109B51B1DC33F2C0099605E /* LayoutManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutManager.swift; sourceTree = ""; }; E11B775F1DBA14B40024E455 /* BlockquoteFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockquoteFormatterTests.swift; sourceTree = ""; }; - E11B77631DBA6ADC0024E455 /* AttributeFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeFormatter.swift; sourceTree = ""; }; - E1C163A41DB6056B00E66A83 /* BlockquoteFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockquoteFormatter.swift; sourceTree = ""; }; - F111DF0E1E3BA393003FB794 /* NSAttributedString+AttributeRanges.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+AttributeRanges.swift"; sourceTree = ""; }; - F111DF101E3BAC8E003FB794 /* NSAttributedStringAttributeRangesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSAttributedStringAttributeRangesTests.swift; sourceTree = ""; }; + F1000CE61EAA44AA0000B15B /* String+EndOfLine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+EndOfLine.swift"; sourceTree = ""; }; + F1000CE81EAA5C720000B15B /* StringEndOfLineTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringEndOfLineTests.swift; sourceTree = ""; }; + F10BE6171EA7ADA6002E4625 /* NSMutableAttributedString+ReplaceOcurrences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSMutableAttributedString+ReplaceOcurrences.swift"; sourceTree = ""; }; + F10BE6191EA7AE9D002E4625 /* NSAttributedString+ReplaceOcurrences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+ReplaceOcurrences.swift"; sourceTree = ""; }; + F10BE61B1EA7B1DB002E4625 /* NSAttributedStringReplaceOcurrencesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSAttributedStringReplaceOcurrencesTests.swift; sourceTree = ""; }; + F11326A91EF1AA91007FEE9A /* Blockquote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Blockquote.swift; sourceTree = ""; }; + F11326AA1EF1AA91007FEE9A /* Header.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; + F11326AB1EF1AA91007FEE9A /* HTMLParagraph.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLParagraph.swift; sourceTree = ""; }; + F11326AC1EF1AA91007FEE9A /* HTMLPre.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLPre.swift; sourceTree = ""; }; + F11326AD1EF1AA91007FEE9A /* ParagraphProperty.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParagraphProperty.swift; sourceTree = ""; }; + F11326AE1EF1AA91007FEE9A /* TextList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextList.swift; sourceTree = ""; }; F11904A41D9D857500BFF9A1 /* TextNodeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextNodeTests.swift; sourceTree = ""; }; F1288BAD1DD0B1EF00E67ABC /* HTMLNodeToNSAttributedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLNodeToNSAttributedString.swift; sourceTree = ""; }; F1288BAE1DD0B1EF00E67ABC /* HTMLToAttributedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLToAttributedString.swift; sourceTree = ""; }; + F12F58511EF20394008AE298 /* AttributeFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeFormatter.swift; sourceTree = ""; }; + F12F58531EF20394008AE298 /* ParagraphAttributeFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParagraphAttributeFormatter.swift; sourceTree = ""; }; + F12F58541EF20394008AE298 /* StandardAttributeFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardAttributeFormatter.swift; sourceTree = ""; }; + F12F58561EF20394008AE298 /* BlockquoteFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockquoteFormatter.swift; sourceTree = ""; }; + F12F58571EF20394008AE298 /* ColorFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorFormatter.swift; sourceTree = ""; }; + F12F58591EF20394008AE298 /* HeaderFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderFormatter.swift; sourceTree = ""; }; + F12F585A1EF20394008AE298 /* HRFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HRFormatter.swift; sourceTree = ""; }; + F12F585B1EF20394008AE298 /* HTMLParagraphFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLParagraphFormatter.swift; sourceTree = ""; }; + F12F585C1EF20394008AE298 /* ImageFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageFormatter.swift; sourceTree = ""; }; + F12F585D1EF20394008AE298 /* LinkFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkFormatter.swift; sourceTree = ""; }; + F12F585E1EF20394008AE298 /* PreFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreFormatter.swift; sourceTree = ""; }; + F12F585F1EF20394008AE298 /* StrikethroughFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StrikethroughFormatter.swift; sourceTree = ""; }; + F12F58601EF20394008AE298 /* TextListFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextListFormatter.swift; sourceTree = ""; }; + F12F58611EF20394008AE298 /* UnderlineFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnderlineFormatter.swift; sourceTree = ""; }; + F12F58621EF20394008AE298 /* VideoFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoFormatter.swift; sourceTree = ""; }; F13806291E3651DC00CFB9ED /* ControlCharacters.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = ControlCharacters.rtf; sourceTree = ""; }; + F14665441EA7C230008DE2B8 /* NSMutableAttributedStringReplaceOcurrencesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSMutableAttributedStringReplaceOcurrencesTests.swift; sourceTree = ""; }; F15C9B871DD58D8B00833C39 /* ElementNodeDescriptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ElementNodeDescriptor.swift; path = Descriptors/ElementNodeDescriptor.swift; sourceTree = ""; }; - F15F61C81E0323EC00CD6DD8 /* EditContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditContext.swift; sourceTree = ""; }; - F17D64AD1E4230A400D09FED /* VisualOnlyAttribute.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisualOnlyAttribute.swift; sourceTree = ""; }; - F17D64AF1E4231C800D09FED /* VisualOnlyElementFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisualOnlyElementFactory.swift; sourceTree = ""; }; + F16DD2CA1EE99B850083A098 /* HTMLRepresentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLRepresentation.swift; sourceTree = ""; }; + F16DD2D21EE99E720083A098 /* HTMLElementRepresentationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLElementRepresentationTests.swift; sourceTree = ""; }; + F16DD2D41EE99E820083A098 /* HTMLElementRepresentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLElementRepresentation.swift; sourceTree = ""; }; + F16DD2D61EE99EA20083A098 /* HTMLAttributeRepresentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLAttributeRepresentation.swift; sourceTree = ""; }; F181CB371E52650F00B256C8 /* NSAttributedString+AttributeDifferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+AttributeDifferences.swift"; sourceTree = ""; }; F18733C41DA096EE005AEB80 /* NSRange+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRange+Helpers.swift"; sourceTree = ""; }; F18733C71DA09737005AEB80 /* NSRangeComparisonTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NSRangeComparisonTests.swift; path = Extensions/NSRangeComparisonTests.swift; sourceTree = ""; }; + F18986E01EF2040A0060EDBA /* FontFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontFormatter.swift; sourceTree = ""; }; + F18986E21EF204180060EDBA /* BoldFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoldFormatter.swift; sourceTree = ""; }; + F18986E41EF2043E0060EDBA /* ItalicFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItalicFormatter.swift; sourceTree = ""; }; + F18B81EA1EA5601000885F43 /* StringUnicodeScalarView+RangeConversion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "StringUnicodeScalarView+RangeConversion.swift"; sourceTree = ""; }; + F18B81EC1EA560B700885F43 /* StringUTF16+RangeConversion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "StringUTF16+RangeConversion.swift"; sourceTree = ""; }; F1A218141E02D5B3000AF5EB /* UndoManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UndoManager.swift; sourceTree = ""; }; F1C05B981E37F99D007510EA /* Character+Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Character+Name.swift"; sourceTree = ""; }; F1C05B9C1E37FA77007510EA /* String+CharacterName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+CharacterName.swift"; sourceTree = ""; }; F1C05B9E1E37FD2F007510EA /* NSAttributedString+CharacterName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+CharacterName.swift"; sourceTree = ""; }; - F1CF27291DBA8CDB0001C61D /* DOMString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DOMString.swift; sourceTree = ""; }; - F1DB0B501E6EF5E600FB4C75 /* DOMInspectorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DOMInspectorTests.swift; sourceTree = ""; }; - F1DB0B521E6EF8AE00FB4C75 /* DOMEditorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DOMEditorTests.swift; sourceTree = ""; }; - F1FA0E721E6EF25A009D98EE /* DOMEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DOMEditor.swift; sourceTree = ""; }; - F1FA0E741E6EF267009D98EE /* DOMLogic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DOMLogic.swift; sourceTree = ""; }; - F1FA0E761E6EF29B009D98EE /* DOMInspector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DOMInspector.swift; sourceTree = ""; }; + F1C55D9D1EE9A976002CE99D /* HTMLAttributeRepresentationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLAttributeRepresentationTests.swift; sourceTree = ""; }; + F1DE83D41EF20493009269E6 /* UIColor+Parsers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Parsers.swift"; sourceTree = ""; }; F1FA0E791E6EF514009D98EE /* Attribute.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Attribute.swift; sourceTree = ""; }; F1FA0E7A1E6EF514009D98EE /* CommentNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentNode.swift; sourceTree = ""; }; F1FA0E7B1E6EF514009D98EE /* ElementNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ElementNode.swift; sourceTree = ""; }; - F1FA0E7C1E6EF514009D98EE /* LeafNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LeafNode.swift; sourceTree = ""; }; F1FA0E7D1E6EF514009D98EE /* MarkerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarkerNode.swift; sourceTree = ""; }; F1FA0E7E1E6EF514009D98EE /* Node.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Node.swift; sourceTree = ""; }; F1FA0E7F1E6EF514009D98EE /* StandardElementType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardElementType.swift; sourceTree = ""; }; F1FA0E801E6EF514009D98EE /* TextNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextNode.swift; sourceTree = ""; }; - F1FFB2A01E6058930015ACB8 /* DOMStringTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DOMStringTests.swift; sourceTree = ""; }; - FF13CD4C1E5C8067000FF10E /* HeaderFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderFormatter.swift; sourceTree = ""; }; + FF0714001EFD78AF00E50713 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; FF152D8D1E68552A00FF596C /* StringRangeConversionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringRangeConversionTests.swift; sourceTree = ""; }; + FF20D63D1EDC389A00294B78 /* HTMLAttributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLAttributes.swift; sourceTree = ""; }; + FF20D63E1EDC389A00294B78 /* HTMLProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLProcessor.swift; sourceTree = ""; }; + FF20D63F1EDC389A00294B78 /* Processor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Processor.swift; sourceTree = ""; }; + FF20D6431EDC395E00294B78 /* NSTextingResult+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSTextingResult+Helpers.swift"; sourceTree = ""; }; + FF20D6461EDC3B3900294B78 /* HTMLProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLProcessorTests.swift; sourceTree = ""; }; + FF24AC981F0146AF003CA91D /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; + FF4E265F1EA8DF1E005E8E42 /* ImageAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageAttachment.swift; sourceTree = ""; }; FF5B98E21DC29D0C00571CA4 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; }; FF5B98E41DC355B400571CA4 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = SOURCE_ROOT; }; FF7A1C481E51EFB600C4C7C8 /* WordPress-Aztec-iOS.podspec */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "WordPress-Aztec-iOS.podspec"; sourceTree = ""; }; FF7A1C4A1E51F05700C4C7C8 /* CONTRIBUTING.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; FF7A1C501E5651EA00C4C7C8 /* LineAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LineAttachment.swift; sourceTree = ""; }; - FF7C89A71E3A2B7C000472A8 /* Blockquote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Blockquote.swift; sourceTree = ""; }; - FF7C89AB1E3A47F1000472A8 /* StandardAttributeFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardAttributeFormatter.swift; sourceTree = ""; }; FF7C89AF1E3BC52F000472A8 /* NSAttributedString+FontTraits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+FontTraits.swift"; sourceTree = ""; }; - FF7DCB461E80837D00AB77CB /* UIColor+Parsers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Parsers.swift"; sourceTree = ""; }; FF7DCB4A1E815F9400AB77CB /* UIColorHexParserTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = UIColorHexParserTests.swift; path = Extensions/UIColorHexParserTests.swift; sourceTree = ""; }; + FF8669EB1EE7677F00F071A6 /* TextViewStubAttachmentDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextViewStubAttachmentDelegate.swift; sourceTree = ""; }; FFA61E881DF18F3D00B71BF6 /* ParagraphStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParagraphStyle.swift; sourceTree = ""; }; FFA61EC11DF6C1C900B71BF6 /* NSAttributedString+Archive.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Archive.swift"; sourceTree = ""; }; FFD0FEB61DAE59A700430586 /* NSLayoutManager+Attachments.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSLayoutManager+Attachments.swift"; sourceTree = ""; }; - FFD436951E300EF700A0E26F /* FontFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontFormatter.swift; sourceTree = ""; }; FFD436971E3180A500A0E26F /* FontFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontFormatterTests.swift; sourceTree = ""; }; + FFFEC7DA1EA7698900F4210F /* VideoAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoAttachment.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -297,6 +365,7 @@ 5951CB901D8BC93600E1866F /* Aztec */ = { isa = PBXGroup; children = ( + FF0713FE1EFD78AF00E50713 /* Assets */, 5951CB921D8BC93600E1866F /* Info.plist */, 599F25001D8BC9A1002871D6 /* Classes */, 599F25911D8BDCA9002871D6 /* Documentation */, @@ -309,11 +378,15 @@ isa = PBXGroup; children = ( 5951CB9E1D8BC93600E1866F /* Info.plist */, + B50CE7351F1FABA40018CAA1 /* Resources */, 59FEA05D1D8BDFA700D138DF /* Exporter */, + B5657C161EE99AE000579FE1 /* Converters */, F18733C61DA0970E005AEB80 /* Extensions */, B5E607311DA56EB000C8A389 /* Formatters */, 59FEA0641D8BDFA700D138DF /* HTML */, 59FEA0691D8BDFA700D138DF /* Importer */, + F16DD2D01EE99E720083A098 /* NSAttributedString */, + FF20D6451EDC3B3900294B78 /* Processor */, F10DB19F1E63390700358E7E /* TextKit */, ); path = AztecTests; @@ -329,7 +402,8 @@ B5B86D3A1DA41A550083DB3F /* Formatters */, 599F25281D8BC9A1002871D6 /* GUI */, 599F25071D8BC9A1002871D6 /* Libxml2 */, - F17D64AC1E4230A400D09FED /* NSAttributedString */, + F16DD2C51EE998280083A098 /* NSAttributedString */, + FF20D63C1EDC389A00294B78 /* Processor */, 599F252E1D8BC9A1002871D6 /* TextKit */, ); name = Classes; @@ -353,8 +427,6 @@ F15C9B891DD58D8F00833C39 /* Descriptors */, 599F25131D8BC9A1002871D6 /* DOM */, 599F251A1D8BC9A1002871D6 /* Libxml2.swift */, - F1CF27291DBA8CDB0001C61D /* DOMString.swift */, - F15F61C81E0323EC00CD6DD8 /* EditContext.swift */, ); path = Libxml2; sourceTree = ""; @@ -383,9 +455,7 @@ 599F250F1D8BC9A1002871D6 /* Out */ = { isa = PBXGroup; children = ( - 599F25101D8BC9A1002871D6 /* OutHTMLAttributeConverter.swift */, - 599F25111D8BC9A1002871D6 /* OutHTMLConverter.swift */, - 599F25121D8BC9A1002871D6 /* OutHTMLNodeConverter.swift */, + B5DA1C1C1EBD1CCE000AAB45 /* OutHTMLConverter.swift */, ); path = Out; sourceTree = ""; @@ -394,7 +464,6 @@ isa = PBXGroup; children = ( F1FA0E781E6EF514009D98EE /* Data */, - F1FA0E711E6EF25A009D98EE /* Logic */, ); path = DOM; sourceTree = ""; @@ -414,23 +483,30 @@ F1C05B981E37F99D007510EA /* Character+Name.swift */, B5F84B601E70595B0089A76C /* NSAttributedString+Analyzers.swift */, FFA61EC11DF6C1C900B71BF6 /* NSAttributedString+Archive.swift */, - F181CB371E52650F00B256C8 /* NSAttributedString+AttributeDifferences.swift */, 599F25251D8BC9A1002871D6 /* NSAttributedString+Attachments.swift */, - F111DF0E1E3BA393003FB794 /* NSAttributedString+AttributeRanges.swift */, + F181CB371E52650F00B256C8 /* NSAttributedString+AttributeDifferences.swift */, F1C05B9E1E37FD2F007510EA /* NSAttributedString+CharacterName.swift */, FF7C89AF1E3BC52F000472A8 /* NSAttributedString+FontTraits.swift */, B5BC4FED1DA2C17800614582 /* NSAttributedString+Lists.swift */, + F10BE6191EA7AE9D002E4625 /* NSAttributedString+ReplaceOcurrences.swift */, + B50CE7311F1FA6260018CAA1 /* NSAttributedString+Strip.swift */, FFD0FEB61DAE59A700430586 /* NSLayoutManager+Attachments.swift */, + F10BE6171EA7ADA6002E4625 /* NSMutableAttributedString+ReplaceOcurrences.swift */, F18733C41DA096EE005AEB80 /* NSRange+Helpers.swift */, + FF20D6431EDC395E00294B78 /* NSTextingResult+Helpers.swift */, F1C05B9C1E37FA77007510EA /* String+CharacterName.swift */, + F1000CE61EAA44AA0000B15B /* String+EndOfLine.swift */, + B5375F461EC2566200F5D7EC /* String+HTML.swift */, 599F25261D8BC9A1002871D6 /* String+RangeConversion.swift */, + F18B81EA1EA5601000885F43 /* StringUnicodeScalarView+RangeConversion.swift */, + F18B81EC1EA560B700885F43 /* StringUTF16+RangeConversion.swift */, B551A49F1E770B3800EE3A7F /* UIFont+Emoji.swift */, B59C9F9E1DF74BB80073B1D6 /* UIFont+Traits.swift */, B5B96DAA1E01B2F300791315 /* UIPasteboard+Helpers.swift */, B5C99D3E1E72E2E700335355 /* UIStackView+Helpers.swift */, 599F25271D8BC9A1002871D6 /* UITextView+Helpers.swift */, + F1DE83D41EF20493009269E6 /* UIColor+Parsers.swift */, F1A218141E02D5B3000AF5EB /* UndoManager.swift */, - FF7DCB461E80837D00AB77CB /* UIColor+Parsers.swift */, ); path = Extensions; sourceTree = ""; @@ -439,6 +515,7 @@ isa = PBXGroup; children = ( 599F25291D8BC9A1002871D6 /* FormatBar */, + FF24AC981F0146AF003CA91D /* Assets.swift */, ); path = GUI; sourceTree = ""; @@ -457,15 +534,19 @@ 599F252E1D8BC9A1002871D6 /* TextKit */ = { isa = PBXGroup; children = ( + F11326A81EF1AA91007FEE9A /* ParagraphProperty */, + B5AF89311E93CFC80051EFDB /* RenderableAttachmentDelegate.swift */, B572AC271E817CFE008948C2 /* CommentAttachment.swift */, - 599F25301D8BC9A1002871D6 /* TextAttachment.swift */, + B57D1C3C1E92C38000EA4B16 /* HTMLAttachment.swift */, FF7A1C501E5651EA00C4C7C8 /* LineAttachment.swift */, - B5BC4FF51DA2D76600614582 /* TextList.swift */, + 599F25301D8BC9A1002871D6 /* MediaAttachment.swift */, + FF4E265F1EA8DF1E005E8E42 /* ImageAttachment.swift */, + FFFEC7DA1EA7698900F4210F /* VideoAttachment.swift */, 599F25311D8BC9A1002871D6 /* TextStorage.swift */, 599F25321D8BC9A1002871D6 /* TextView.swift */, E109B51B1DC33F2C0099605E /* LayoutManager.swift */, FFA61E881DF18F3D00B71BF6 /* ParagraphStyle.swift */, - FF7C89A71E3A2B7C000472A8 /* Blockquote.swift */, + B5A99D831EBA073D00DED081 /* HTMLStorage.swift */, ); path = TextKit; sourceTree = ""; @@ -508,9 +589,7 @@ 59FEA05D1D8BDFA700D138DF /* Exporter */ = { isa = PBXGroup; children = ( - 59FEA05E1D8BDFA700D138DF /* OutAttributeConverterTests.swift */, - 59FEA05F1D8BDFA700D138DF /* OutHTMLConverterTests.swift */, - 59FEA0601D8BDFA700D138DF /* OutNodeConverterTests.swift */, + B5DA1C1E1EBD1EA7000AAB45 /* OutHTMLConverterTests.swift */, ); path = Exporter; sourceTree = ""; @@ -518,9 +597,6 @@ 59FEA0641D8BDFA700D138DF /* HTML */ = { isa = PBXGroup; children = ( - F1DB0B521E6EF8AE00FB4C75 /* DOMEditorTests.swift */, - F1DB0B501E6EF5E600FB4C75 /* DOMInspectorTests.swift */, - F1FFB2A01E6058930015ACB8 /* DOMStringTests.swift */, 59FEA0651D8BDFA700D138DF /* ElementNodeTests.swift */, 59FEA0661D8BDFA700D138DF /* NodeTests.swift */, F11904A41D9D857500BFF9A1 /* TextNodeTests.swift */, @@ -539,15 +615,36 @@ path = Importer; sourceTree = ""; }; + B50CE7351F1FABA40018CAA1 /* Resources */ = { + isa = PBXGroup; + children = ( + B50CE7331F1FABA00018CAA1 /* content.html */, + ); + name = Resources; + sourceTree = ""; + }; + B5657C161EE99AE000579FE1 /* Converters */ = { + isa = PBXGroup; + children = ( + B5657C171EE99AF500579FE1 /* NSAttributedStringToNodesTests.swift */, + B5A5277D1EF1BAC000E7D2FD /* HTMLNodeToNSAttributedStringTests.swift */, + ); + path = Converters; + sourceTree = ""; + }; + B5A5277A1EF1B44800E7D2FD /* Styles */ = { + isa = PBXGroup; + children = ( + B5A5277B1EF1B44800E7D2FD /* UnsupportedHTML.swift */, + ); + path = Styles; + sourceTree = ""; + }; B5B86D3A1DA41A550083DB3F /* Formatters */ = { isa = PBXGroup; children = ( - E11B77631DBA6ADC0024E455 /* AttributeFormatter.swift */, - FF7C89AB1E3A47F1000472A8 /* StandardAttributeFormatter.swift */, - E1C163A41DB6056B00E66A83 /* BlockquoteFormatter.swift */, - FF13CD4C1E5C8067000FF10E /* HeaderFormatter.swift */, - B5B86D3B1DA41A550083DB3F /* TextListFormatter.swift */, - FFD436951E300EF700A0E26F /* FontFormatter.swift */, + F12F58501EF20394008AE298 /* Base */, + F12F58551EF20394008AE298 /* Implementations */, ); path = Formatters; sourceTree = ""; @@ -558,6 +655,7 @@ B5E607321DA56EC700C8A389 /* TextListFormatterTests.swift */, E11B775F1DBA14B40024E455 /* BlockquoteFormatterTests.swift */, FFD436971E3180A500A0E26F /* FontFormatterTests.swift */, + B542D6411E9EB122009D12D3 /* PreFormaterTests.swift */, ); path = Formatters; sourceTree = ""; @@ -567,19 +665,66 @@ children = ( 599F25931D8BDCFC002871D6 /* TextViewTests.swift */, 599F25921D8BDCFC002871D6 /* TextStorageTests.swift */, + FF8669EB1EE7677F00F071A6 /* TextViewStubAttachmentDelegate.swift */, ); name = TextKit; sourceTree = ""; }; + F11326A81EF1AA91007FEE9A /* ParagraphProperty */ = { + isa = PBXGroup; + children = ( + F11326A91EF1AA91007FEE9A /* Blockquote.swift */, + F11326AA1EF1AA91007FEE9A /* Header.swift */, + F11326AB1EF1AA91007FEE9A /* HTMLParagraph.swift */, + F11326AC1EF1AA91007FEE9A /* HTMLPre.swift */, + F11326AD1EF1AA91007FEE9A /* ParagraphProperty.swift */, + F11326AE1EF1AA91007FEE9A /* TextList.swift */, + ); + path = ParagraphProperty; + sourceTree = ""; + }; F1288BAC1DD0B1EF00E67ABC /* Converters */ = { isa = PBXGroup; children = ( F1288BAD1DD0B1EF00E67ABC /* HTMLNodeToNSAttributedString.swift */, F1288BAE1DD0B1EF00E67ABC /* HTMLToAttributedString.swift */, + B5657C141EE99A8600579FE1 /* NSAttributedStringToNodes.swift */, ); path = Converters; sourceTree = ""; }; + F12F58501EF20394008AE298 /* Base */ = { + isa = PBXGroup; + children = ( + F12F58511EF20394008AE298 /* AttributeFormatter.swift */, + F18986E01EF2040A0060EDBA /* FontFormatter.swift */, + F12F58531EF20394008AE298 /* ParagraphAttributeFormatter.swift */, + F12F58541EF20394008AE298 /* StandardAttributeFormatter.swift */, + ); + path = Base; + sourceTree = ""; + }; + F12F58551EF20394008AE298 /* Implementations */ = { + isa = PBXGroup; + children = ( + F12F58561EF20394008AE298 /* BlockquoteFormatter.swift */, + F18986E21EF204180060EDBA /* BoldFormatter.swift */, + F12F58571EF20394008AE298 /* ColorFormatter.swift */, + F12F58591EF20394008AE298 /* HeaderFormatter.swift */, + F12F585A1EF20394008AE298 /* HRFormatter.swift */, + F12F585B1EF20394008AE298 /* HTMLParagraphFormatter.swift */, + F12F585C1EF20394008AE298 /* ImageFormatter.swift */, + F18986E41EF2043E0060EDBA /* ItalicFormatter.swift */, + F12F585D1EF20394008AE298 /* LinkFormatter.swift */, + F12F585E1EF20394008AE298 /* PreFormatter.swift */, + F12F585F1EF20394008AE298 /* StrikethroughFormatter.swift */, + F12F58601EF20394008AE298 /* TextListFormatter.swift */, + F12F58611EF20394008AE298 /* UnderlineFormatter.swift */, + F12F58621EF20394008AE298 /* VideoFormatter.swift */, + ); + path = Implementations; + sourceTree = ""; + }; F15C9B891DD58D8F00833C39 /* Descriptors */ = { isa = PBXGroup; children = ( @@ -590,45 +735,64 @@ name = Descriptors; sourceTree = ""; }; - F17D64AC1E4230A400D09FED /* NSAttributedString */ = { + F16DD2C51EE998280083A098 /* NSAttributedString */ = { isa = PBXGroup; children = ( - F17D64AD1E4230A400D09FED /* VisualOnlyAttribute.swift */, - F17D64AF1E4231C800D09FED /* VisualOnlyElementFactory.swift */, + F16DD2C91EE99B850083A098 /* HTML */, + B5A5277A1EF1B44800E7D2FD /* Styles */, ); path = NSAttributedString; sourceTree = ""; }; + F16DD2C91EE99B850083A098 /* HTML */ = { + isa = PBXGroup; + children = ( + F16DD2D61EE99EA20083A098 /* HTMLAttributeRepresentation.swift */, + F16DD2D41EE99E820083A098 /* HTMLElementRepresentation.swift */, + F16DD2CA1EE99B850083A098 /* HTMLRepresentation.swift */, + ); + path = HTML; + sourceTree = ""; + }; + F16DD2D01EE99E720083A098 /* NSAttributedString */ = { + isa = PBXGroup; + children = ( + F16DD2D11EE99E720083A098 /* HTML */, + ); + path = NSAttributedString; + sourceTree = ""; + }; + F16DD2D11EE99E720083A098 /* HTML */ = { + isa = PBXGroup; + children = ( + F1C55D9D1EE9A976002CE99D /* HTMLAttributeRepresentationTests.swift */, + F16DD2D21EE99E720083A098 /* HTMLElementRepresentationTests.swift */, + ); + path = HTML; + sourceTree = ""; + }; F18733C61DA0970E005AEB80 /* Extensions */ = { isa = PBXGroup; children = ( - B5BC4FF11DA2D17000614582 /* NSAttributedStringListsTests.swift */, B5F84B621E706B720089A76C /* NSAttributedStringAnalyzerTests.swift */, - F111DF101E3BAC8E003FB794 /* NSAttributedStringAttributeRangesTests.swift */, + B5BC4FF11DA2D17000614582 /* NSAttributedStringListsTests.swift */, + F10BE61B1EA7B1DB002E4625 /* NSAttributedStringReplaceOcurrencesTests.swift */, + F14665441EA7C230008DE2B8 /* NSMutableAttributedStringReplaceOcurrencesTests.swift */, F18733C71DA09737005AEB80 /* NSRangeComparisonTests.swift */, + F1000CE81EAA5C720000B15B /* StringEndOfLineTests.swift */, + B5375F481EC2569500F5D7EC /* StringHTMLTests.swift */, FF152D8D1E68552A00FF596C /* StringRangeConversionTests.swift */, FF7DCB4A1E815F9400AB77CB /* UIColorHexParserTests.swift */, ); name = Extensions; sourceTree = ""; }; - F1FA0E711E6EF25A009D98EE /* Logic */ = { - isa = PBXGroup; - children = ( - F1FA0E721E6EF25A009D98EE /* DOMEditor.swift */, - F1FA0E741E6EF267009D98EE /* DOMLogic.swift */, - F1FA0E761E6EF29B009D98EE /* DOMInspector.swift */, - ); - path = Logic; - sourceTree = ""; - }; F1FA0E781E6EF514009D98EE /* Data */ = { isa = PBXGroup; children = ( F1FA0E791E6EF514009D98EE /* Attribute.swift */, F1FA0E7A1E6EF514009D98EE /* CommentNode.swift */, F1FA0E7B1E6EF514009D98EE /* ElementNode.swift */, - F1FA0E7C1E6EF514009D98EE /* LeafNode.swift */, F1FA0E7D1E6EF514009D98EE /* MarkerNode.swift */, F1FA0E7E1E6EF514009D98EE /* Node.swift */, F1FA0E7F1E6EF514009D98EE /* StandardElementType.swift */, @@ -637,6 +801,32 @@ path = Data; sourceTree = ""; }; + FF0713FE1EFD78AF00E50713 /* Assets */ = { + isa = PBXGroup; + children = ( + FF0714001EFD78AF00E50713 /* Media.xcassets */, + ); + path = Assets; + sourceTree = ""; + }; + FF20D63C1EDC389A00294B78 /* Processor */ = { + isa = PBXGroup; + children = ( + FF20D63D1EDC389A00294B78 /* HTMLAttributes.swift */, + FF20D63E1EDC389A00294B78 /* HTMLProcessor.swift */, + FF20D63F1EDC389A00294B78 /* Processor.swift */, + ); + path = Processor; + sourceTree = ""; + }; + FF20D6451EDC3B3900294B78 /* Processor */ = { + isa = PBXGroup; + children = ( + FF20D6461EDC3B3900294B78 /* HTMLProcessorTests.swift */, + ); + path = Processor; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -694,7 +884,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0800; - LastUpgradeCheck = 0800; + LastUpgradeCheck = 0900; ORGANIZATIONNAME = "Automattic Inc."; TargetAttributes = { 5951CB8D1D8BC93600E1866F = { @@ -734,6 +924,7 @@ buildActionMask = 2147483647; files = ( F138062A1E3651DC00CFB9ED /* ControlCharacters.rtf in Resources */, + FF0714021EFD78AF00E50713 /* Media.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -741,6 +932,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B50CE7341F1FABA00018CAA1 /* content.html in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -752,73 +944,100 @@ buildActionMask = 2147483647; files = ( FF7A1C511E5651EA00C4C7C8 /* LineAttachment.swift in Sources */, + FF20D6441EDC395E00294B78 /* NSTextingResult+Helpers.swift in Sources */, + F12F586C1EF20394008AE298 /* HTMLParagraphFormatter.swift in Sources */, + F12F58661EF20394008AE298 /* StandardAttributeFormatter.swift in Sources */, + F16DD2D51EE99E820083A098 /* HTMLElementRepresentation.swift in Sources */, + B5A99D841EBA073D00DED081 /* HTMLStorage.swift in Sources */, 599F25471D8BC9A1002871D6 /* HTMLConstants.swift in Sources */, 599F25371D8BC9A1002871D6 /* InAttributeConverter.swift in Sources */, 599F25531D8BC9A1002871D6 /* TextStorage.swift in Sources */, B572AC281E817CFE008948C2 /* CommentAttachment.swift in Sources */, F1FA0E861E6EF514009D98EE /* Node.swift in Sources */, - 599F253C1D8BC9A1002871D6 /* OutHTMLAttributeConverter.swift in Sources */, 599F254B1D8BC9A1002871D6 /* String+RangeConversion.swift in Sources */, 599F25481D8BC9A1002871D6 /* Metrics.swift in Sources */, F181CB381E52650F00B256C8 /* NSAttributedString+AttributeDifferences.swift in Sources */, F1288BAF1DD0B1EF00E67ABC /* HTMLNodeToNSAttributedString.swift in Sources */, B59C9F9F1DF74BB80073B1D6 /* UIFont+Traits.swift in Sources */, + F10BE61A1EA7AE9D002E4625 /* NSAttributedString+ReplaceOcurrences.swift in Sources */, + F11326B41EF1AA91007FEE9A /* TextList.swift in Sources */, + F18986E51EF2043E0060EDBA /* ItalicFormatter.swift in Sources */, + F11326B01EF1AA91007FEE9A /* Header.swift in Sources */, F1C05B9F1E37FD2F007510EA /* NSAttributedString+CharacterName.swift in Sources */, + FF20D6411EDC389A00294B78 /* HTMLProcessor.swift in Sources */, + F16DD2CB1EE99B850083A098 /* HTMLRepresentation.swift in Sources */, B5C99D3F1E72E2E700335355 /* UIStackView+Helpers.swift in Sources */, F1C05B991E37F99D007510EA /* Character+Name.swift in Sources */, + FF20D6421EDC389A00294B78 /* Processor.swift in Sources */, 599F253B1D8BC9A1002871D6 /* InNodesConverter.swift in Sources */, - B5B86D3C1DA41A550083DB3F /* TextListFormatter.swift in Sources */, + F18B81EB1EA5601000885F43 /* StringUnicodeScalarView+RangeConversion.swift in Sources */, + F11326B31EF1AA91007FEE9A /* ParagraphProperty.swift in Sources */, + F11326B11EF1AA91007FEE9A /* HTMLParagraph.swift in Sources */, + FF20D6401EDC389A00294B78 /* HTMLAttributes.swift in Sources */, + F12F58701EF20394008AE298 /* StrikethroughFormatter.swift in Sources */, + FF4E26601EA8DF1E005E8E42 /* ImageAttachment.swift in Sources */, 599F254F1D8BC9A1002871D6 /* FormatBarItem.swift in Sources */, + F18B81ED1EA560B700885F43 /* StringUTF16+RangeConversion.swift in Sources */, + F12F58631EF20394008AE298 /* AttributeFormatter.swift in Sources */, B577DC651E7B18E90012A1F8 /* NodeDescriptor.swift in Sources */, 599F254E1D8BC9A1002871D6 /* FormatBarDelegate.swift in Sources */, - F15F61C91E0323EC00CD6DD8 /* EditContext.swift in Sources */, - F1FA0E731E6EF25A009D98EE /* DOMEditor.swift in Sources */, + B5375F471EC2566200F5D7EC /* String+HTML.swift in Sources */, F1C05B9D1E37FA77007510EA /* String+CharacterName.swift in Sources */, - F1FA0E841E6EF514009D98EE /* LeafNode.swift in Sources */, + FFFEC7DB1EA7698900F4210F /* VideoAttachment.swift in Sources */, + F12F586D1EF20394008AE298 /* ImageFormatter.swift in Sources */, + F12F58671EF20394008AE298 /* BlockquoteFormatter.swift in Sources */, + B5A5277C1EF1B44800E7D2FD /* UnsupportedHTML.swift in Sources */, F1288BB01DD0B1EF00E67ABC /* HTMLToAttributedString.swift in Sources */, - E11B77641DBA6ADC0024E455 /* AttributeFormatter.swift in Sources */, + B5DA1C1D1EBD1CCE000AAB45 /* OutHTMLConverter.swift in Sources */, + F12F58681EF20394008AE298 /* ColorFormatter.swift in Sources */, 599F25351D8BC9A1002871D6 /* CLinkedListToArrayConverter.swift in Sources */, - 599F25521D8BC9A1002871D6 /* TextAttachment.swift in Sources */, + 599F25521D8BC9A1002871D6 /* MediaAttachment.swift in Sources */, + F1000CE71EAA44AA0000B15B /* String+EndOfLine.swift in Sources */, F1FA0E831E6EF514009D98EE /* ElementNode.swift in Sources */, FFD0FEB71DAE59A700430586 /* NSLayoutManager+Attachments.swift in Sources */, 599F25541D8BC9A1002871D6 /* TextView.swift in Sources */, - E1C163A51DB6056B00E66A83 /* BlockquoteFormatter.swift in Sources */, 599F254A1D8BC9A1002871D6 /* NSAttributedString+Attachments.swift in Sources */, + F1DE83D51EF20493009269E6 /* UIColor+Parsers.swift in Sources */, B5B96DAB1E01B2F300791315 /* UIPasteboard+Helpers.swift in Sources */, - F17D64AE1E4230A400D09FED /* VisualOnlyAttribute.swift in Sources */, F15C9B881DD58D8B00833C39 /* ElementNodeDescriptor.swift in Sources */, - FF7C89A81E3A2B7C000472A8 /* Blockquote.swift in Sources */, + F12F58721EF20394008AE298 /* UnderlineFormatter.swift in Sources */, + F12F58711EF20394008AE298 /* TextListFormatter.swift in Sources */, + F11326B21EF1AA91007FEE9A /* HTMLPre.swift in Sources */, + F18986E11EF2040A0060EDBA /* FontFormatter.swift in Sources */, + F12F586B1EF20394008AE298 /* HRFormatter.swift in Sources */, 599F254C1D8BC9A1002871D6 /* UITextView+Helpers.swift in Sources */, FFA61E891DF18F3D00B71BF6 /* ParagraphStyle.swift in Sources */, - F1FA0E751E6EF267009D98EE /* DOMLogic.swift in Sources */, - FF7DCB471E80837D00AB77CB /* UIColor+Parsers.swift in Sources */, - FFD436961E300EF800A0E26F /* FontFormatter.swift in Sources */, - F1FA0E771E6EF29B009D98EE /* DOMInspector.swift in Sources */, + F12F58651EF20394008AE298 /* ParagraphAttributeFormatter.swift in Sources */, + F12F58731EF20394008AE298 /* VideoFormatter.swift in Sources */, + F18986E31EF204180060EDBA /* BoldFormatter.swift in Sources */, 599F25451D8BC9A1002871D6 /* Libxml2.swift in Sources */, FF7C89B01E3BC52F000472A8 /* NSAttributedString+FontTraits.swift in Sources */, - B5BC4FF61DA2D76600614582 /* TextList.swift in Sources */, + F16DD2D71EE99EA20083A098 /* HTMLAttributeRepresentation.swift in Sources */, F1FA0E821E6EF514009D98EE /* CommentNode.swift in Sources */, + B57D1C3D1E92C38000EA4B16 /* HTMLAttachment.swift in Sources */, F1FA0E871E6EF514009D98EE /* StandardElementType.swift in Sources */, + B5657C151EE99A8600579FE1 /* NSAttributedStringToNodes.swift in Sources */, 599F25391D8BC9A1002871D6 /* InHTMLConverter.swift in Sources */, + F10BE6181EA7ADA6002E4625 /* NSMutableAttributedString+ReplaceOcurrences.swift in Sources */, F1A218151E02D5B3000AF5EB /* UndoManager.swift in Sources */, + F11326AF1EF1AA91007FEE9A /* Blockquote.swift in Sources */, FFA61EC21DF6C1C900B71BF6 /* NSAttributedString+Archive.swift in Sources */, - 599F253E1D8BC9A1002871D6 /* OutHTMLNodeConverter.swift in Sources */, E109B51C1DC33F2C0099605E /* LayoutManager.swift in Sources */, F1FA0E851E6EF514009D98EE /* MarkerNode.swift in Sources */, + B5AF89321E93CFC80051EFDB /* RenderableAttachmentDelegate.swift in Sources */, B5BC4FEE1DA2C17800614582 /* NSAttributedString+Lists.swift in Sources */, B5F84B611E70595B0089A76C /* NSAttributedString+Analyzers.swift in Sources */, 599F25361D8BC9A1002871D6 /* Converter.swift in Sources */, - 599F253D1D8BC9A1002871D6 /* OutHTMLConverter.swift in Sources */, 599F25381D8BC9A1002871D6 /* InAttributesConverter.swift in Sources */, B551A4A01E770B3800EE3A7F /* UIFont+Emoji.swift in Sources */, + F12F586E1EF20394008AE298 /* LinkFormatter.swift in Sources */, + F12F586A1EF20394008AE298 /* HeaderFormatter.swift in Sources */, + FF24AC991F0146AF003CA91D /* Assets.swift in Sources */, 599F254D1D8BC9A1002871D6 /* FormatBar.swift in Sources */, - FF7C89AC1E3A47F1000472A8 /* StandardAttributeFormatter.swift in Sources */, F1FA0E881E6EF514009D98EE /* TextNode.swift in Sources */, 599F253A1D8BC9A1002871D6 /* InNodeConverter.swift in Sources */, - F1CF272A1DBA8CDB0001C61D /* DOMString.swift in Sources */, - F17D64B01E4231C800D09FED /* VisualOnlyElementFactory.swift in Sources */, - F111DF0F1E3BA393003FB794 /* NSAttributedString+AttributeRanges.swift in Sources */, - FF13CD4D1E5C8067000FF10E /* HeaderFormatter.swift in Sources */, + F12F586F1EF20394008AE298 /* PreFormatter.swift in Sources */, + B50CE7321F1FA6260018CAA1 /* NSAttributedString+Strip.swift in Sources */, B5B86D371DA3EC250083DB3F /* NSRange+Helpers.swift in Sources */, B577DC671E7B18F80012A1F8 /* CommentNodeDescriptor.swift in Sources */, 599F25501D8BC9A1002871D6 /* FormattingIdentifier.swift in Sources */, @@ -833,24 +1052,29 @@ files = ( FF7DCB4B1E815F9400AB77CB /* UIColorHexParserTests.swift in Sources */, 594C9D741D8BE6C700D74542 /* InNodeConverterTests.swift in Sources */, + B5A5277E1EF1BAC000E7D2FD /* HTMLNodeToNSAttributedStringTests.swift in Sources */, B5F84B631E706B720089A76C /* NSAttributedStringAnalyzerTests.swift in Sources */, + F10BE61C1EA7B1DB002E4625 /* NSAttributedStringReplaceOcurrencesTests.swift in Sources */, 59FEA0751D8BDFA700D138DF /* NodeTests.swift in Sources */, 59FEA0781D8BDFA700D138DF /* HTMLToAttributedStringTests.swift in Sources */, - F1DB0B511E6EF5E600FB4C75 /* DOMInspectorTests.swift in Sources */, + B5375F491EC2569500F5D7EC /* StringHTMLTests.swift in Sources */, + F14665451EA7C230008DE2B8 /* NSMutableAttributedStringReplaceOcurrencesTests.swift in Sources */, F18733C81DA09737005AEB80 /* NSRangeComparisonTests.swift in Sources */, - F111DF111E3BAC8E003FB794 /* NSAttributedStringAttributeRangesTests.swift in Sources */, B5E607331DA56EC700C8A389 /* TextListFormatterTests.swift in Sources */, - F1DB0B531E6EF8AE00FB4C75 /* DOMEditorTests.swift in Sources */, + FF20D6471EDC3B3900294B78 /* HTMLProcessorTests.swift in Sources */, 594C9D731D8BE6C300D74542 /* InAttributeConverterTests.swift in Sources */, F11904A51D9D857500BFF9A1 /* TextNodeTests.swift in Sources */, + FF8669EC1EE7677F00F071A6 /* TextViewStubAttachmentDelegate.swift in Sources */, FF152D8E1E68552A00FF596C /* StringRangeConversionTests.swift in Sources */, - F1FFB2A11E6058930015ACB8 /* DOMStringTests.swift in Sources */, + F1C55D9E1EE9A976002CE99D /* HTMLAttributeRepresentationTests.swift in Sources */, 59FEA07A1D8BDFA700D138DF /* InHTMLConverterTests.swift in Sources */, - 594C9D721D8BE6BC00D74542 /* OutNodeConverterTests.swift in Sources */, + F16DD2D31EE99E720083A098 /* HTMLElementRepresentationTests.swift in Sources */, E11B77601DBA14B40024E455 /* BlockquoteFormatterTests.swift in Sources */, 599F25951D8BDCFC002871D6 /* TextViewTests.swift in Sources */, - 594C9D711D8BE6B800D74542 /* OutAttributeConverterTests.swift in Sources */, - 59FEA06F1D8BDFA700D138DF /* OutHTMLConverterTests.swift in Sources */, + B5DA1C1F1EBD1EA7000AAB45 /* OutHTMLConverterTests.swift in Sources */, + F1000CE91EAA5C720000B15B /* StringEndOfLineTests.swift in Sources */, + B5657C181EE99AF500579FE1 /* NSAttributedStringToNodesTests.swift in Sources */, + B542D6421E9EB122009D12D3 /* PreFormaterTests.swift in Sources */, 599F25941D8BDCFC002871D6 /* TextStorageTests.swift in Sources */, 59FEA0741D8BDFA700D138DF /* ElementNodeTests.swift in Sources */, B5BC4FF21DA2D17000614582 /* NSAttributedStringListsTests.swift in Sources */, @@ -878,7 +1102,9 @@ CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -886,7 +1112,11 @@ CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; @@ -932,7 +1162,9 @@ CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -940,7 +1172,11 @@ CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; @@ -1071,7 +1307,9 @@ CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1079,7 +1317,11 @@ CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; @@ -1165,7 +1407,9 @@ CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1173,7 +1417,11 @@ CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; diff --git a/Aztec.xcodeproj/xcshareddata/xcschemes/Aztec.xcscheme b/Aztec.xcodeproj/xcshareddata/xcschemes/Aztec.xcscheme index f969db317..a249c5b65 100644 --- a/Aztec.xcodeproj/xcshareddata/xcschemes/Aztec.xcscheme +++ b/Aztec.xcodeproj/xcshareddata/xcschemes/Aztec.xcscheme @@ -1,6 +1,6 @@ NSAttributedString { - return convert(node, inheritingAttributes: defaultAttributes) + return convert(node, inheriting: defaultAttributes) } /// Recursive conversion method. Useful for maintaining the font style of parent nodes when @@ -52,18 +50,16 @@ class HMTLNodeToNSAttributedString: SafeConverter { /// /// - Returns: the converted node as an `NSAttributedString`. /// - fileprivate func convert(_ node: Node, inheritingAttributes attributes: [String:Any]) -> NSAttributedString { - - if let textNode = node as? TextNode { - return convertTextNode(textNode, inheritingAttributes: attributes) - } else if let commentNode = node as? CommentNode { - return convertCommentNode(commentNode, inheritingAttributes: attributes) - } else { - guard let elementNode = node as? ElementNode else { - fatalError("Nodes can be either text or element nodes.") - } - - return convertElementNode(elementNode, inheritingAttributes: attributes) + fileprivate func convert(_ node: Node, inheriting attributes: [String:Any]) -> NSAttributedString { + switch node { + case let textNode as TextNode: + return convert(textNode, inheriting: attributes) + case let commentNode as CommentNode: + return convert(commentNode, inheriting: attributes) + case let elementNode as ElementNode: + return convert(elementNode, inheriting: attributes) + default: + fatalError("Nodes can be either text, comment or element nodes.") } } @@ -75,18 +71,21 @@ class HMTLNodeToNSAttributedString: SafeConverter { /// /// - Returns: the converted node as an `NSAttributedString`. /// - fileprivate func convertTextNode(_ node: TextNode, inheritingAttributes inheritedAttributes: [String:Any]) -> NSAttributedString { - guard node.length() > 0 else { - return NSAttributedString() - } + fileprivate func convert(_ node: TextNode, inheriting attributes: [String:Any]) -> NSAttributedString { + + let string: NSAttributedString - let content = NSMutableAttributedString(string: node.text(), attributes: inheritedAttributes) + if node.length() == 0 { + string = NSAttributedString() + } else { + string = NSAttributedString(string: node.text(), attributes: attributes) + } - if node.isLastInBlockLevelElement() { - content.append(visualOnlyElementFactory.newline(inheritingAttributes: inheritedAttributes)) + guard !node.needsClosingParagraphSeparator() else { + return appendParagraphSeparator(to: string, inheriting: attributes) } - return content + return string } /// Converts a `CommentNode` to `NSAttributedString`. @@ -97,7 +96,7 @@ class HMTLNodeToNSAttributedString: SafeConverter { /// /// - Returns: the converted node as an `NSAttributedString`. /// - fileprivate func convertCommentNode(_ node: CommentNode, inheritingAttributes attributes: [String:Any]) -> NSAttributedString { + fileprivate func convert(_ node: CommentNode, inheriting attributes: [String:Any]) -> NSAttributedString { let attachment = CommentAttachment() attachment.text = node.comment @@ -112,123 +111,92 @@ class HMTLNodeToNSAttributedString: SafeConverter { /// /// - Returns: the converted node as an `NSAttributedString`. /// - fileprivate func convertElementNode(_ node: ElementNode, inheritingAttributes attributes: [String:Any]) -> NSAttributedString { - return stringForNode(node, inheritingAttributes: attributes) - } + fileprivate func convert(_ element: ElementNode, inheriting attributes: [String: Any]) -> NSAttributedString { - // MARK: - Node Styling + guard element.isSupportedByEditor() else { + return convert(unsupported: element, inheriting: attributes) + } - /// Returns an attributed string representing the specified node. - /// - /// - Parameters: - /// - node: the element node to generate a representation string of. - /// - inheritedAttributes: the inherited attributes from parent nodes. - /// - /// - Returns: the attributed string representing the specified element node. - /// - /// - fileprivate func stringForNode(_ node: ElementNode, inheritingAttributes inheritedAttributes: [String:Any]) -> NSAttributedString { - - let childAttributes = attributes(forNode: node, inheritingAttributes: inheritedAttributes) - - if let nodeType = node.standardName, + let childAttributes = self.attributes(for: element, inheriting: attributes) + let content = NSMutableAttributedString() + + if let nodeType = element.standardName, let implicitRepresentation = nodeType.implicitRepresentation(withAttributes: childAttributes) { - - return implicitRepresentation + + content.append(implicitRepresentation) + } else { + for child in element.children { + let childContent = convert(child, inheriting: childAttributes) + content.append(childContent) + } } - - let content = NSMutableAttributedString() - - for child in node.children { - let childContent = convert(child, inheritingAttributes: childAttributes) - content.append(childContent) + + guard !element.needsClosingParagraphSeparator() else { + return appendParagraphSeparator(to: content, inheriting: attributes) } - + return content } - // MARK: - String attributes + fileprivate func convert(unsupported element: ElementNode, inheriting attributes: [String:Any]) -> NSAttributedString { + let converter = Libxml2.Out.HTMLConverter() + let attachment = HTMLAttachment() - /// Calculates the attributes for the specified node. Returns a dictionary including inherited - /// attributes. - /// - /// - Parameters: - /// - node: the node to get the information from. - /// - /// - Returns: an attributes dictionary, for use in an NSAttributedString. - /// - fileprivate func attributes(forNode node: ElementNode, inheritingAttributes inheritedAttributes: [String:Any]) -> [String:Any] { + attachment.rootTagName = element.name + attachment.rawHTML = converter.convert(element) - var attributes = inheritedAttributes + return NSAttributedString(attachment: attachment, attributes: attributes) + } - var attributeValue: Any? + // MARK: - Paragraph Separator - if node.isNodeType(.a) { - var linkURL: URL? + private func appendParagraphSeparator(to string: NSAttributedString, inheriting inheritedAttributes: [String: Any]) -> NSAttributedString { - if let attributeIndex = node.attributes.index(where: { $0.name == HTMLLinkAttribute.Href.rawValue }), - let attribute = node.attributes[attributeIndex] as? StringAttribute { + let stringWithSeparator = NSMutableAttributedString(attributedString: string) - linkURL = URL(string: attribute.value) - } else { - // We got a link tag without an HREF attribute - // - linkURL = URL(string: "") - } + stringWithSeparator.append(NSAttributedString(.paragraphSeparator, attributes: inheritedAttributes)) - attributeValue = linkURL - } + return NSAttributedString(attributedString: stringWithSeparator) + } - if node.isNodeType(.img) { - let url: URL? - if let urlString = node.valueForStringAttribute(named: "src") { - url = URL(string: urlString) - } else { - url = nil - } + // MARK: - Node Styling - let attachment = TextAttachment(identifier: UUID().uuidString, url: url) - - if let elementClass = node.valueForStringAttribute(named: "class") { - let classAttributes = elementClass.components(separatedBy: " ") - for classAttribute in classAttributes { - if let alignment = TextAttachment.Alignment.fromHTML(string: classAttribute) { - attachment.alignment = alignment - } - if let size = TextAttachment.Size.fromHTML(string: classAttribute) { - attachment.size = size - } - } - } - attributeValue = attachment - } + /// Returns an attributed string representing the specified node. + /// + /// - Parameters: + /// - node: the element node to generate a representation string of. + /// - attributes: the inherited attributes from parent nodes. + /// + /// - Returns: the attributed string representing the specified element node. + /// + /// + fileprivate func string(for element: ElementNode, inheriting attributes: [String:Any]) -> NSAttributedString { + + let childAttributes = self.attributes(for: element, inheriting: attributes) + let content = NSMutableAttributedString() - if node.isNodeType(.span) { - if let elementStyle = node.valueForStringAttribute(named: "style") { - let styles = parseStyle(style: elementStyle) - if !styles.isEmpty, let colorString = styles["color"] { - attributeValue = UIColor(hexString: colorString) - } - } - } + if let nodeType = element.standardName, + let implicitRepresentation = nodeType.implicitRepresentation(withAttributes: childAttributes) { - for (key, formatter) in elementToFormattersMap { - if node.isNodeType(key) { - if let standardValueFormatter = formatter as? StandardAttributeFormatter, - let value = attributeValue { - standardValueFormatter.attributeValue = value - } - attributes = formatter.apply(to: attributes); + content.append(implicitRepresentation) + } else { + for child in element.children { + let childContent = convert(child, inheriting: childAttributes) + content.append(childContent) } } - return attributes + guard !element.needsClosingParagraphSeparator() else { + return appendParagraphSeparator(to: content, inheriting: attributes) + } + + return content } public let elementToFormattersMap: [StandardElementType: AttributeFormatter] = [ - .ol: TextListFormatter(style: .ordered), - .ul: TextListFormatter(style: .unordered), + .ol: TextListFormatter(style: .ordered, increaseDepth: true), + .ul: TextListFormatter(style: .unordered, increaseDepth: true), .blockquote: BlockquoteFormatter(), .strong: BoldFormatter(), .em: ItalicFormatter(), @@ -243,7 +211,14 @@ class HMTLNodeToNSAttributedString: SafeConverter { .h4: HeaderFormatter(headerLevel: .h4), .h5: HeaderFormatter(headerLevel: .h5), .h6: HeaderFormatter(headerLevel: .h6), - .span: ColorFormatter() + .p: HTMLParagraphFormatter(), + .pre: PreFormatter(), + .video: VideoFormatter() + ] + + public let styleToFormattersMap: [String: (AttributeFormatter, (String)->Any?)] = [ + "color": (ColorFormatter(), {(value) in return UIColor(hexString: value)}), + "text-decoration": (UnderlineFormatter(), { (value) in return value == "underline" ? NSUnderlineStyle.styleSingle.rawValue : nil}) ] func parseStyle(style: String) -> [String: String] { @@ -261,3 +236,126 @@ class HMTLNodeToNSAttributedString: SafeConverter { return stylesDictionary } } + +private extension HTMLNodeToNSAttributedString { + + // MARK: - NSAttributedString attribute generation + + /// Calculates the attributes for the specified node. Returns a dictionary including inherited + /// attributes. + /// + /// - Parameters: + /// - node: the node to get the information from. + /// + /// - Returns: an attributes dictionary, for use in an NSAttributedString. + /// + func attributes(for element: ElementNode, inheriting attributes: [String: Any]) -> [String: Any] { + + guard !(element is RootNode) else { + return attributes + } + + let elementRepresentation = HTMLElementRepresentation(for: element) + return self.attributes(for: elementRepresentation, inheriting: attributes) + } + + /// Calculates the attributes for the specified element representation. Returns a dictionary + /// including inherited attributes. + /// + /// - Parameters: + /// - elementRepresentation: the element representation. + /// - inheritedAttributes: the attributes that will be inherited. + /// + /// - Returns: an attributes dictionary, for use in an NSAttributedString. + /// + private func attributes(for elementRepresentation: HTMLElementRepresentation, inheriting attributes: [String: Any]) -> [String: Any] { + + var finalAttributes = attributes + + if let elementFormatter = formatter(for: elementRepresentation) { + finalAttributes = elementFormatter.apply(to: finalAttributes, andStore: elementRepresentation) + } else if elementRepresentation.name == StandardElementType.li.rawValue { + // ^ Since LI is handled by the OL and UL formatters, we can safely ignore it here. + + finalAttributes = attributes + } else { + finalAttributes = self.attributes(storing: elementRepresentation, in: finalAttributes) + } + + for attributeRepresentation in elementRepresentation.attributes { + finalAttributes = self.attributes(for: attributeRepresentation, inheriting: finalAttributes) + } + + return finalAttributes + } + + + /// Calculates the attributes for the specified element representation. Returns a dictionary + /// including inherited attributes. + /// + /// - Parameters: + /// - attributeRepresentation: the element representation. + /// - inheritedAttributes: the attributes that will be inherited. + /// + /// - Returns: an attributes dictionary, for use in an NSAttributedString. + /// + private func attributes(for attributeRepresentation: HTMLAttributeRepresentation, inheriting inheritedAttributes: [String: Any]) -> [String: Any] { + + let attributes: [String:Any] + + if let attributeFormatter = formatter(for: attributeRepresentation) { + attributes = attributeFormatter.apply(to: inheritedAttributes, andStore: attributeRepresentation) + } else { + attributes = inheritedAttributes + } + + return attributes + } + + + /// Stores the specified HTMLElementRepresentation in a collection of NSAttributedString Attributes. + /// + /// - Parameters: + /// - elementRepresentation: Instance of HTMLElementRepresentation to be stored. + /// - attributes: Attributes where we should store the HTML Representation. + /// + /// - Returns: A collection of NSAttributedString Attributes, including the specified HTMLElementRepresentation. + /// + private func attributes(storing elementRepresentation: HTMLElementRepresentation, in attributes: [String: Any]) -> [String: Any] { + let unsupportedHTML = attributes[UnsupportedHTMLAttributeName] as? UnsupportedHTML ?? UnsupportedHTML() + unsupportedHTML.add(element: elementRepresentation) + + var updated = attributes + updated[UnsupportedHTMLAttributeName] = unsupportedHTML + + return updated + } +} + +extension HTMLNodeToNSAttributedString { + + // MARK: - Formatters + + func formatter(for representation: HTMLAttributeRepresentation) -> AttributeFormatter? { + // TODO: implement attribute representation formatters + // + return nil + } + + func formatter(for representation: HTMLElementRepresentation) -> AttributeFormatter? { + + guard let standardType = StandardElementType(rawValue: representation.name) else { + return nil + } + + let equivalentNames = standardType.equivalentNames + + for (key, formatter) in elementToFormattersMap { + if equivalentNames.contains(key.rawValue) { + return formatter + } + } + + return nil + } +} diff --git a/Aztec/Classes/Converters/HTMLToAttributedString.swift b/Aztec/Classes/Converters/HTMLToAttributedString.swift index c6f07f083..cacea1325 100644 --- a/Aztec/Classes/Converters/HTMLToAttributedString.swift +++ b/Aztec/Classes/Converters/HTMLToAttributedString.swift @@ -1,31 +1,27 @@ import Foundation import UIKit -class HTMLToAttributedString: Converter { +class HTMLToAttributedString: SafeConverter { - typealias EditContext = Libxml2.EditContext typealias RootNode = Libxml2.RootNode typealias TextNode = Libxml2.TextNode /// The default font descriptor that will be used as a base for conversions. /// let defaultFontDescriptor: UIFontDescriptor - - let editContext: EditContext? - required init(usingDefaultFontDescriptor defaultFontDescriptor: UIFontDescriptor, editContext: EditContext? = nil) { + required init(usingDefaultFontDescriptor defaultFontDescriptor: UIFontDescriptor) { self.defaultFontDescriptor = defaultFontDescriptor - self.editContext = editContext } - func convert(_ html: String) throws -> (rootNode: RootNode, attributedString: NSAttributedString) { - let htmlToNode = Libxml2.In.HTMLConverter(editContext: editContext) - let nodeToAttributedString = HMTLNodeToNSAttributedString(usingDefaultFontDescriptor: defaultFontDescriptor) + func convert(_ html: String) -> (rootNode: RootNode, attributedString: NSAttributedString) { + let htmlToNode = Libxml2.In.HTMLConverter() + let nodeToAttributedString = HTMLNodeToNSAttributedString(usingDefaultFontDescriptor: defaultFontDescriptor) - let rootNode = try htmlToNode.convert(html) + let rootNode = htmlToNode.convert(html) if rootNode.children.count == 0 { - rootNode.append(TextNode(text: html, editContext: editContext)) + rootNode.children.append(TextNode(text: html)) } let attributedString = nodeToAttributedString.convert(rootNode) diff --git a/Aztec/Classes/Converters/NSAttributedStringToNodes.swift b/Aztec/Classes/Converters/NSAttributedStringToNodes.swift new file mode 100644 index 000000000..6c67a79c9 --- /dev/null +++ b/Aztec/Classes/Converters/NSAttributedStringToNodes.swift @@ -0,0 +1,789 @@ +import Foundation +import UIKit +import libxml2 + + +// MARK: - NSAttributedStringToNodes +// +class NSAttributedStringToNodes: Converter { + + /// Typealiases + /// + typealias Node = Libxml2.Node + typealias CommentNode = Libxml2.CommentNode + typealias ElementNode = Libxml2.ElementNode + typealias RootNode = Libxml2.RootNode + typealias TextNode = Libxml2.TextNode + typealias Attribute = Libxml2.Attribute + typealias StringAttribute = Libxml2.StringAttribute + typealias StandardElementType = Libxml2.StandardElementType + + + /// Converts an Attributed String Instance into it's HTML Tree Representation. + /// + /// - Parameter attrString: Attributed String that should be converted. + /// + /// - Returns: RootNode, representing the DOM Tree. + /// + func convert(_ attrString: NSAttributedString) -> RootNode { + var nodes = [Node]() + var previous: [Node]? + + attrString.enumerateParagraphRanges(spanning: attrString.rangeOfEntireString) { (paragraphRange, _) in + let paragraph = attrString.attributedSubstring(from: paragraphRange) + let children = createNodes(fromParagraph: paragraph) + + if let previous = previous { + let left = rightmostParagraphStyleElements(from: previous) + let right = leftmostParagraphStyleElements(from: children) + + guard !merge(left: left, right: right) else { + return + } + + if left.count == 0 && right.count == 0 { + nodes += [ ElementNode(type: .br) ] + } + } + + nodes += children + previous = children + } + + return RootNode(children: nodes) + } + + + /// Converts a *Paragraph* into a collection of Nodes, representing the internal HTML Entities. + /// + /// - Parameter paragraph: Paragraph's Attributed String that should be converted. + /// + /// - Returns: Array of Node instances. + /// + private func createNodes(fromParagraph paragraph: NSAttributedString) -> [Node] { + guard paragraph.length > 0 else { + return [] + } + + var branches = [Branch]() + + paragraph.enumerateAttributes(in: paragraph.rangeOfEntireString, options: []) { (attrs, range, _) in + + let substring = paragraph.attributedSubstring(from: range) + let leafNodes = createLeafNodes(from: substring) + let styleNodes = createStyleNodes(from: attrs) + + let branch = Branch(nodes: styleNodes, leaves: leafNodes) + branches.append(branch) + } + + let paragraphNodes = createParagraphNodes(from: paragraph) + let processedBranches = process(branches: branches) + + return reduce(nodes: paragraphNodes, leaves: processedBranches) + } +} + + +// MARK: - Merge: Helpers +// +private extension NSAttributedStringToNodes { + + /// Defines a Tree Branch: Collection of Nodes, with a set of Leaves + /// + typealias Branch = (nodes: [ElementNode], leaves: [Node]) + + + /// Defines a pair of Nodes that can be merged + /// + typealias MergeablePair = (left: ElementNode, right: ElementNode) + + + /// Sets Up a collection of Nodes and Leaves as a chain of Parent-Children, and returns the root node.and + /// If the collection of nodes is empty, will return the leaves parameters 'as is'. + /// + func reduce(nodes: [ElementNode], leaves: [Node]) -> [Node] { + return nodes.reduce(leaves) { (result, node) in + node.children = result + return [node] + } + } + + + /// Finds the Deepest node that can be merged "Right to Left", and returns the Left / Right matching touple, if any. + /// + func findMergeableNodes(left: [ElementNode], right: [ElementNode], blocklevelEnforced: Bool = true) -> [MergeablePair]? { + var currentIndex = 0 + var matching = [MergeablePair]() + + while currentIndex < left.count && currentIndex < right.count { + let left = left[currentIndex] + let right = right[currentIndex] + + guard left.canMergeChildren(of: right, blocklevelEnforced: blocklevelEnforced) else { + break + } + + let pair = MergeablePair(left: left, right: right) + matching.append(pair) + currentIndex += 1 + } + + return matching.isEmpty ? nil : matching + } +} + + +// MARK: - Merge: Styles +// +private extension NSAttributedStringToNodes { + + /// Given a collection of branches, this method will iterate branch by branch and will: + /// + /// A. Reduce the Nodes: An actuall Parent/Child relationship will be set + /// B. Attempt to merge the current Branch with the Previous Branch + /// C. Return the collection of Reduced + Merged Nodes + /// + func process(branches: [Branch]) -> [Node] { + let sorted = sort(branches: branches) + var merged = [Node]() + var previous: Branch? + + for branch in sorted { + if let left = previous , let current = merge(left: left, right: branch) { + previous = current + continue + } + + let reduced = reduce(nodes: branch.nodes.reversed(), leaves: branch.leaves) + merged.append(contentsOf: reduced) + previous = branch + } + + return merged + } + + + /// Attempts to merge the Right Branch into the Left Branch. On success, we'll return a newly created + /// branch, containing the 'Left-Matched-Elements' + 'Right-Unmathed-Elements' + 'Right-Leaves'. + /// + private func merge(left: Branch, right: Branch) -> Branch? { + guard let mergeableCandidate = findMergeableNodes(left: left.nodes, right: right.nodes, blocklevelEnforced: false), + let target = mergeableCandidate.last?.left + else { + return nil + } + + let mergeableLeftNodes = mergeableCandidate.flatMap { $0.left } + let mergeableRightNodes = mergeableCandidate.flatMap { $0.right } + + // Reduce: Non Mergeable Right Subtree + let nonMergeableRightNodesSet = Set(right.nodes).subtracting(mergeableRightNodes) + let nonMergeableRightNodes = Array(nonMergeableRightNodesSet) + + let source = reduce(nodes: nonMergeableRightNodes, leaves: right.leaves) + + // Merge: Move the 'Non Mergeable Right Subtree' to the left merging spot + target.children += source + + // Regen: Branch with the actual used instances! + let mergedNodes = mergeableLeftNodes + nonMergeableRightNodes + return Branch(nodes: mergedNodes, leaves: right.leaves) + } + + + /// Arranges a collection of Branches in a (Hopefully) "Defragmented" way: + /// + /// - Nodes will be sorted 'By Length'. Longer nodes will appear on top + /// - Nodes that existed in the previous branch are expected to maintain the exact same position + /// + private func sort(branches: [Branch]) -> [Branch] { + var output = [Branch]() + var previous = [ElementNode]() + + for (index, branch) in branches.enumerated() { + let lengths = lengthOfElements(atColumnIndex: index, in: branches) + + // Split Duplicates: Nodes that existed in the previous collection (while retaining their original position!) + let (sorted, unsorted) = splitDuplicateNodes(in: branch.nodes, comparingWith: previous) + + // Sort 'Branch New Items' + Consolidate + let consolidated = sorted + unsorted.sorted(by: { lengths[$0]! > lengths[$1]! }) + + let updated = Branch(nodes: consolidated, leaves: branch.leaves) + output.append(updated) + previous = consolidated + } + + return output + } + + + /// Splits a collection of Nodes in two groups: 'Nodes that also exist in a Reference Collection', and + /// 'Completely New Nodes'. + /// + /// *Note*: The order of those Pre Existing nodes will be arranged in the exact same way as they appear + /// in the reference collection. + /// + private func splitDuplicateNodes(in current: [ElementNode], comparingWith previous: [ElementNode]) -> ([ElementNode], [ElementNode]) { + var duplicates = [ElementNode]() + var nonDuplicates = [ElementNode]() + + for node in previous where current.contains(node) { + guard let index = current.index(of: node) else { + continue + } + + let target = current[index] + duplicates.append(target) + } + + for node in current where !duplicates.contains(node) { + nonDuplicates.append(node) + } + + return (duplicates, nonDuplicates) + } + + + /// Determines the length of (ALL) of the Nodes at a specified Column, given a collection of Branches. + /// + private func lengthOfElements(atColumnIndex index: Int, in branches: [Branch]) -> [ElementNode: Int] { + var lengths = [ElementNode: Int]() + var rightmost = branches + rightmost.removeFirst(index) + + for node in branches[index].nodes { + lengths[node] = length(of: node, in: rightmost) + } + + return lengths + } + + + /// Determines the length of a Node, given a collection of branches. + /// + private func length(of element: ElementNode, in branches: [Branch]) -> Int { + var length = 0 + + for branch in branches { + if !branch.nodes.contains(element) { + break + } + + length += 1 + } + + return length + } +} + + +// MARK: - Merge: Paragraphs +// +private extension NSAttributedStringToNodes { + + /// Attempts to merge the Right array of Element Nodes (Paragraph Level) into the Left array of Nodes. + /// + /// - We expect two collections of Mergeable Elements: Paragraph Level, with matching Names + Attributes + /// + func merge(left: [ElementNode], right: [ElementNode]) -> Bool { + guard let mergeableCandidates = findMergeableNodes(left: left, right: right) else { + return false + } + + guard let (leftMerger, rightMerger) = mergeablePair(from: mergeableCandidates) else { + return false + } + + leftMerger.children += rightMerger.children + + return true + } + + + /// Finds the last valid Mergeable Pair within a collection of mergeable nodes + /// + /// - Last LI item is never merged + /// - Last 'Mergeable' element is never merged (ie.

Hello\nWorld

>>

Hello

World

+ /// + private func mergeablePair(from mergeableNodes: [MergeablePair]) -> MergeablePair? { + + // Business logic: The last mergeable node is never merged, so we need more than 1 node to continue. + // + guard mergeableNodes.count > 1, + let lastNodeName = mergeableNodes.last?.left.name + else { + return nil + } + + var mergeCandidates = mergeableNodes.dropLast() + + if lastNodeName != "li" { + mergeCandidates = prefix(upToLast: "li", from: mergeCandidates) + } + + return mergeCandidates.last + } + + + /// Slices the specified array until the last LI node. For instance: + /// + /// - Input: [.ul, .li, .h1] + /// + /// - Output: [.ul] + /// + private func prefix(upToLast name: String, from nodes: ArraySlice) -> ArraySlice { + var lastItemIndex: Int? + for (index, node) in nodes.enumerated().reversed() where node.left.name == name { + lastItemIndex = index + break + } + + guard let sliceIndex = lastItemIndex else { + return nodes + } + + return nodes[0.. [ElementNode] { + return paragraphStyleElements(from: nodes) { children in + return children.last + } + } + + + /// Returns the "Leftmost" Blocklevel Node from a collection fo nodes. + /// + func leftmostParagraphStyleElements(from nodes: [Node]) -> [ElementNode] { + return paragraphStyleElements(from: nodes) { children in + return children.first + } + } + + + /// Returns a children Blocklevel Node from a collection of nodes, using a Child Picker to determine the + /// navigational-direction. + /// + private func paragraphStyleElements(from nodes: [Node], childPicker: (([Node]) -> Node?)) -> [ElementNode] { + var elements = [ElementNode]() + var nextElement = childPicker(nodes) as? ElementNode + + while let currentElement = nextElement { + guard currentElement.isBlockLevelElement() else { + break + } + + elements.append(currentElement) + nextElement = childPicker(currentElement.children) as? ElementNode + } + + return elements + } +} + + +// MARK: - Paragraph Nodes: Alloc'ation +// +private extension NSAttributedStringToNodes { + + /// Extracts the ElementNodes contained within a Paragraph's AttributedString. + /// + /// - Parameters: + /// - attrString: Paragraph's AttributedString from which we intend to extract the ElementNode + /// + /// - Returns: ElementNode representing the specified Paragraph. + /// + func createParagraphNodes(from attrString: NSAttributedString) -> [ElementNode] { + guard let paragraphStyle = attrString.attribute(NSParagraphStyleAttributeName, at: 0, effectiveRange: nil) as? ParagraphStyle else { + return [] + } + + var paragraphNodes = [ElementNode]() + + for property in paragraphStyle.properties.reversed() { + switch property { + case let blockquote as Blockquote: + paragraphNodes += processBlockquoteStyle(blockquote: blockquote) + + case let header as Header: + paragraphNodes += processHeaderStyle(header: header) + + case let list as TextList: + paragraphNodes += processListStyle(list: list) + + case let paragraph as HTMLParagraph: + paragraphNodes += processParagraphStyle(paragraph: paragraph) + + case let pre as HTMLPre: + paragraphNodes += processPreStyle(pre: pre) + + default: + continue + } + } + + return paragraphNodes + } + + + /// Extracts all of the Blockquote Elements contained within a collection of Attributes. + /// + private func processBlockquoteStyle(blockquote: Blockquote) -> [ElementNode] { + let node = blockquote.representation?.toNode() ?? ElementNode(type: .blockquote) + return [node] + } + + + /// Extracts all of the Header Elements contained within a collection of Attributes. + /// + private func processHeaderStyle(header: Header) -> [ElementNode] { + guard let type = ElementNode.elementTypeForHeaderLevel(header.level.rawValue) else { + return [] + } + + let node = header.representation?.toNode() ?? ElementNode(type: type) + return [node] + } + + + /// Extracts all of the List Elements contained within a collection of Attributes. + /// + private func processListStyle(list: TextList) -> [ElementNode] { + let elementType = list.style == .ordered ? StandardElementType.ol : StandardElementType.ul + let listElement = list.representation?.toNode() ?? ElementNode(type: elementType) + let itemElement = ElementNode(type: .li) + + // TODO: LI needs it's Original Attributes!! + return [itemElement, listElement] + } + + + /// Extracts all of the Paragraph Elements contained within a collection of Attributes. + /// + private func processParagraphStyle(paragraph: HTMLParagraph) -> [ElementNode] { + let node = paragraph.representation?.toNode() ?? ElementNode(type: .p) + return [node] + } + + + /// Extracts all of the Pre Elements contained within a collection of Attributes. + /// + private func processPreStyle(pre: HTMLPre) -> [ElementNode] { + let node = pre.representation?.toNode() ?? ElementNode(type: .pre) + return [node] + } +} + + +// MARK: - Style Nodes: Alloc'ation +// +private extension NSAttributedStringToNodes { + + /// Extracts all of the Style Nodes contained within a collection of AttributedString Attributes. + /// + /// - Parameters: + /// - attrs: Collection of attributes that should be converted. + /// + /// - Returns: Style Nodes contained within the specified collection of attributes + /// + func createStyleNodes(from attributes: [String: Any]) -> [ElementNode] { + var nodes = [ElementNode]() + + nodes += processFontStyle(in: attributes) + nodes += processLinkStyle(in: attributes) + nodes += processStrikethruStyle(in: attributes) + nodes += processUnderlineStyle(in: attributes) + nodes += processUnsupportedHTML(in: attributes) + + return nodes + } + + + /// Extracts all of the Font Elements contained within a collection of Attributes. + /// + private func processFontStyle(in attributes: [String: Any]) -> [ElementNode] { + guard let font = attributes[NSFontAttributeName] as? UIFont else { + return [] + } + + var nodes = [ElementNode]() + + if font.containsTraits(.traitBold) { + let representation = attributes[BoldFormatter.htmlRepresentationKey] as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .b) + + nodes.append(node) + } + + if font.containsTraits(.traitItalic) { + let representation = attributes[ItalicFormatter.htmlRepresentationKey] as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .i) + + nodes.append(node) + } + + return nodes + } + + + /// Extracts all of the Link Elements contained within a collection of Attributes. + /// + private func processLinkStyle(in attributes: [String: Any]) -> [ElementNode] { + guard let url = attributes[NSLinkAttributeName] as? URL else { + return [] + } + + let representation = attributes[LinkFormatter.htmlRepresentationKey] as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .a) + node.updateAttribute(named: "href", value: url.absoluteString) + + return [node] + } + + + /// Extracts all of the Strike Elements contained within a collection of Attributes. + /// + private func processStrikethruStyle(in attributes: [String: Any]) -> [ElementNode] { + guard attributes[NSStrikethroughStyleAttributeName] != nil else { + return [] + } + + let representation = attributes[StrikethroughFormatter.htmlRepresentationKey] as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .strike) + + return [node] + } + + + /// Extracts all of the Underline Elements contained within a collection of Attributes. + /// + private func processUnderlineStyle(in attributes: [String: Any]) -> [ElementNode] { + guard attributes[NSUnderlineStyleAttributeName] != nil else { + return [] + } + + let representation = attributes[UnderlineFormatter.htmlRepresentationKey] as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .u) + + return [node] + } + + + /// Extracts all of the Unsupported HTML Snippets contained within a collection of Attributes. + /// + private func processUnsupportedHTML(in attributes: [String: Any]) -> [ElementNode] { + guard let unsupported = attributes[UnsupportedHTMLAttributeName] as? UnsupportedHTML else { + return [] + } + + return unsupported.elements.reversed().flatMap({ element in + return element.toNode() + }) + } +} + + +// MARK: - Leaf Nodes: Alloc'ation +// +private extension NSAttributedStringToNodes { + + /// Extract all of the Leaf Nodes contained within an Attributed String. We consider the following as Leaf: + /// Plain Text, Attachments of any kind [Line, Comment, HTML, Image]. + /// + /// - Parameter attrString: AttributedString that should be converted. + /// + /// - Returns: Leaf Nodes contained within the specified collection of attributes + /// + func createLeafNodes(from attrString: NSAttributedString) -> [Node] { + var nodes = [Node]() + + nodes += processLineAttachment(from: attrString) + nodes += processCommentAttachment(from: attrString) + nodes += processHtmlAttachment(from: attrString) + nodes += processImageAttachment(from: attrString) + nodes += processVideoAttachment(from: attrString) + + return nodes.isEmpty ? processTextNodes(from: attrString.string) : nodes + } + + /// Converts a Line Attachment into it's representing nodes. + /// + private func processLineAttachment(from attrString: NSAttributedString) -> [Node] { + guard attrString.attribute(NSAttachmentAttributeName, at: 0, effectiveRange: nil) is LineAttachment else { + return [] + } + + let range = attrString.rangeOfEntireString + let representation = attrString.attribute(HRFormatter.htmlRepresentationKey, at: 0, longestEffectiveRange: nil, in: range) as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .hr) + return [node] + } + + + /// Converts a Comment Attachment into it's representing nodes. + /// + private func processCommentAttachment(from attrString: NSAttributedString) -> [Node] { + guard let attachment = attrString.attribute(NSAttachmentAttributeName, at: 0, effectiveRange: nil) as? CommentAttachment else { + return [] + } + + let node = CommentNode(text: attachment.text) + return [node] + } + + + /// Converts an HTML Attachment into it's representing nodes. + /// + private func processHtmlAttachment(from attrString: NSAttributedString) -> [Node] { + guard let attachment = attrString.attribute(NSAttachmentAttributeName, at: 0, effectiveRange: nil) as? HTMLAttachment else { + return [] + } + + let converter = Libxml2.In.HTMLConverter() + + let rootNode = converter.convert(attachment.rawHTML) + + guard let firstChild = rootNode.children.first else { + return processTextNodes(from: attachment.rawHTML) + } + + guard rootNode.children.count == 1 else { + let node = ElementNode(type: .span, attributes: [], children: rootNode.children) + return [node] + } + + return [firstChild] + } + + + /// Converts an Image Attachment into it's representing nodes. + /// + private func processImageAttachment(from attrString: NSAttributedString) -> [Node] { + guard let attachment = attrString.attribute(NSAttachmentAttributeName, at: 0, effectiveRange: nil) as? ImageAttachment else { + return [] + } + + let range = attrString.rangeOfEntireString + let representation = attrString.attribute(ImageFormatter.htmlRepresentationKey, at: 0, longestEffectiveRange: nil, in: range) as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .img) + + if let attribute = imageSourceAttribute(from: attachment) { + node.updateAttribute(named: attribute.name, value: attribute.value) + } + + if let attribute = imageClassAttribute(from: attachment) { + node.updateAttribute(named: attribute.name, value: attribute.value) + } + + return [node] + } + + /// Converts an Video Attachment into it's representing nodes. + /// + private func processVideoAttachment(from attrString: NSAttributedString) -> [Node] { + guard let attachment = attrString.attribute(NSAttachmentAttributeName, at: 0, effectiveRange: nil) as? VideoAttachment else { + return [] + } + + let range = attrString.rangeOfEntireString + let representation = attrString.attribute(VideoFormatter.htmlRepresentationKey, at: 0, longestEffectiveRange: nil, in: range) as? HTMLElementRepresentation + let node = representation?.toNode() ?? ElementNode(type: .video) + + if let attribute = videoSourceAttribute(from: attachment) { + node.updateAttribute(named: attribute.name, value: attribute.value) + } + + if let attribute = videoPosterAttribute(from: attachment) { + node.updateAttribute(named: attribute.name, value: attribute.value) + } + + for (key,value) in attachment.namedAttributes { + node.updateAttribute(named: key, value: value) + } + + return [node] + } + + + /// Converts a String into it's representing nodes. + /// + private func processTextNodes(from text: String) -> [Node] { + let substrings = text.components(separatedBy: String(.lineSeparator)) + var output = [Node]() + + for (index, substring) in substrings.enumerated() { + + output.append(TextNode(text: substring)) + + if index < substrings.count - 1 { + output.append(ElementNode(type: .br)) + } + } + + return output + } + + + /// Extracts the Video Source Attribute from a VideoAttachment Instance. + /// + private func videoSourceAttribute(from attachment: VideoAttachment) -> StringAttribute? { + guard let source = attachment.srcURL?.absoluteString else { + return nil + } + + return StringAttribute(name: "src", value: source) + } + + + /// Extracts the Video Poster Attribute from a VideoAttachment Instance. + /// + private func videoPosterAttribute(from attachment: VideoAttachment) -> StringAttribute? { + guard let poster = attachment.posterURL?.absoluteString else { + return nil + } + + return StringAttribute(name: "poster", value: poster) + } + + + /// Extracts the src attribute from an ImageAttachment Instance. + /// + private func imageSourceAttribute(from attachment: ImageAttachment) -> StringAttribute? { + guard let source = attachment.url?.absoluteString else { + return nil + } + + return StringAttribute(name: "src", value: source) + } + + + /// Extracts the class attribute from an ImageAttachment Instance. + /// + private func imageClassAttribute(from attachment: ImageAttachment) -> StringAttribute? { + var style = String() + if attachment.alignment != .center { + style += attachment.alignment.htmlString() + } + + if attachment.size != .full { + style += style.isEmpty ? String() : String(.space) + style += attachment.size.htmlString() + } + + guard !style.isEmpty else { + return nil + } + + return StringAttribute(name: "class", value: style) + } +} diff --git a/Aztec/Classes/Extensions/Character+Name.swift b/Aztec/Classes/Extensions/Character+Name.swift index b3222002d..b6258cd54 100644 --- a/Aztec/Classes/Extensions/Character+Name.swift +++ b/Aztec/Classes/Extensions/Character+Name.swift @@ -1,10 +1,17 @@ import Foundation +import UIKit extension Character { - + enum Name: Character { - case newline = "\n" + case lineFeed = "\u{000A}" + case carriageReturn = "\u{000D}" + case nonBreakingSpace = "\u{00A0}" + case lineSeparator = "\u{2028}" + case objectReplacement = "\u{FFFC}" + case paragraphSeparator = "\u{2029}" case space = " " + case tab = "\t" case zeroWidthSpace = "\u{200B}" } diff --git a/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift b/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift index 2b936206b..d9351f6e6 100644 --- a/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift +++ b/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift @@ -27,4 +27,15 @@ extension NSAttributedString { return attribute(NSLinkAttributeName, at: afterRange.location, effectiveRange: nil) != nil } + + /// Returns the Substring at the specified range, whenever the received range is valid, or nil + /// otherwise. + /// + func safeSubstring(at range: NSRange) -> String? { + guard range.location >= 0 && range.endLocation <= length else { + return nil + } + + return attributedSubstring(from: range).string + } } diff --git a/Aztec/Classes/Extensions/NSAttributedString+AttributeRanges.swift b/Aztec/Classes/Extensions/NSAttributedString+AttributeRanges.swift deleted file mode 100644 index 1e6631777..000000000 --- a/Aztec/Classes/Extensions/NSAttributedString+AttributeRanges.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Foundation - -extension NSAttributedString { - - // MARK: - Attribute filtering - - func filter(attributeNamed attributeName: String) -> NSAttributedString { - let result = NSMutableAttributedString() - - enumerateAttribute(attributeName, in: rangeOfEntireString, options: []) { (attributeValue, subRange, stop) in - if attributeValue == nil { - result.append(attributedSubstring(from: subRange)) - } - } - - return result - } - - // MARK: - Range mapping by attribute filtering - - /// Maps a range by subtracting the length of all instanced of a specified attribute in that - /// range. - /// - /// - Parameters: - /// - initialRange: the range to map. - /// - attributeName: the attribute to subract from the provided range. - /// - /// - Returns: the mapped range. - /// - func map(range initialRange: NSRange, bySubtractingAttributeNamed attributeName: String) -> NSRange { - - // We need to also inspect anything before initialRange, because attributes in that range - // affect the mapping as well. - // - let rangeToInspect = NSRange(location: 0, length: initialRange.location + initialRange.length) - let ranges = self.ranges(forAttributeNamed: attributeName, within: rangeToInspect) - - guard ranges.count > 0 else { - return initialRange - } - - var mappedRange = initialRange - - for range in ranges.reversed() { - - if range.contains(range: mappedRange) { - mappedRange.location = range.location - mappedRange.length = 0 - continue - } - - let rangeEndLocation = range.location + range.length - let mappedRangeEndLocation = mappedRange.location + mappedRange.endLocation - - if rangeEndLocation <= mappedRange.location { - mappedRange.location = mappedRange.location - range.length - } else if range.location < mappedRange.location && mappedRange.location < rangeEndLocation { - - // Order of execution is important in the next 2 lines, as mappedRange.location - // is read first and written-to afterwards. - // - mappedRange.length = mappedRangeEndLocation - rangeEndLocation - mappedRange.location = range.location - } else { - mappedRange.length = mappedRange.length - range.length - } - } - - return mappedRange - } - - - // MARK: - Finding attribute ranges - - /// Determine the ranges in which an attribute is present. - /// - /// - Parameters: - /// - attributeName: the name of the attribute to find the ranges of. - /// - range: the subrange in which the search will be performed. All found ranges will - /// be inside this range. - /// - /// - Returns: an array of ranges where the attribute can be found - /// - func ranges(forAttributeNamed attributeName: String) -> [NSRange] { - return ranges(forAttributeNamed: attributeName, within: NSRange(location: 0, length: length)) - } - - /// Determine the ranges in which an attribute is present. - /// - /// - Parameters: - /// - attributeName: the name of the attribute to find the ranges of. - /// - range: the subrange in which the search will be performed. All found ranges will - /// be inside this range. - /// - /// - Returns: an array of ranges where the attribute can be found - /// - func ranges(forAttributeNamed attributeName: String, within range: NSRange) -> [NSRange] { - var result = [NSRange]() - - enumerateAttribute(attributeName, in: range, options: []) { (matchingValue, matchingRange, nil) in - guard matchingValue != nil else { - return - } - - result.append(matchingRange) - } - - return result - } -} diff --git a/Aztec/Classes/Extensions/NSAttributedString+Lists.swift b/Aztec/Classes/Extensions/NSAttributedString+Lists.swift index 2dad45d90..75e834078 100644 --- a/Aztec/Classes/Extensions/NSAttributedString+Lists.swift +++ b/Aztec/Classes/Extensions/NSAttributedString+Lists.swift @@ -4,69 +4,7 @@ import UIKit // MARK: - NSAttributedString Lists Helpers // -extension NSAttributedString -{ - /// Check if the location passed is the beggining of a new line. - /// - /// - Parameter location: the position to check - /// - Returns: true if beggining of a new line false otherwise - /// - func isStartOfNewLine(atLocation location: Int) -> Bool { - var isStartOfLine = length == 0 || location == 0 - if length > 0 && location > 0 { - let previousRange = NSRange(location: location - 1, length: 1) - let previousString = attributedSubstring(from: previousRange).string - isStartOfLine = previousString == String(.newline) - } - return isStartOfLine - } - - /// Check if the location passed is the beggining of a new list line. - /// - /// - Parameter location: the position to check - /// - Returns: true if beggining of a new line false otherwise - /// - func isStartOfNewListItem(atLocation location: Int) -> Bool { - var isStartOfListItem = attribute(NSParagraphStyleAttributeName, at: location, effectiveRange: nil) != nil - var isStartOfLine = length == 0 || location == 0 - if length > 0 && location > 0 { - let previousRange = NSRange(location: location - 1, length: 1) - let previousString = attributedSubstring(from: previousRange) - isStartOfLine = previousString.string == String(.newline) - isStartOfListItem = previousString.textListAttribute(atIndex: 0) != nil - } - return isStartOfLine && isStartOfListItem - } - - /// Given a collection of NSRange's, this method will filter all of those that contain a TextList, and - /// don't match the specified Style. - /// - /// - Parameters: - /// - ranges: Ranges to be filtered - /// - style: Style to be matched - /// - /// - Returns: A subset of the input ranges that don't contain TextLists matching the input style. - /// - func filterListRanges(_ ranges: [NSRange], notMatchingStyle style: TextList.Style) -> [NSRange] { - return ranges.filter { range in - let list = textListAttribute(spanningRange: range) - return list == nil || list?.style == style - } - } - - /// Get the range of a TextList containing the specified index. - /// - /// - Parameter index: An index intersecting a TextList. - /// - /// - Returns: An NSRange optional containing the range of the list or nil if no list was found. - /// - func rangeOfTextList(atIndex index: Int) -> NSRange? { - guard let textList = textListAttribute(atIndex: index) else { - return nil - } - - return range(of: textList, at: index) - } +extension NSAttributedString { /// Returns the range of the given text list that contains the given location. /// @@ -81,30 +19,40 @@ extension NSAttributedString let targetRange = rangeOfEntireString guard let paragraphStyle = attribute(NSParagraphStyleAttributeName, at: location, longestEffectiveRange: &effectiveRange, in: targetRange) as? ParagraphStyle, - let foundList = paragraphStyle.textList, + let foundList = paragraphStyle.lists.last, foundList == list else { return nil } + let listDepth = paragraphStyle.lists.count + var resultRange = effectiveRange //Note: The effective range will only return the range of the in location NSParagraphStyleAttributed // but this can be different on preceding or suceeding range but is the same TextList, // so we need to expand the range to grab all the TextList coverage. while resultRange.location > 0 { - if + guard let paragraphStyle = attribute(NSParagraphStyleAttributeName, at: resultRange.location-1, longestEffectiveRange: &effectiveRange, in: targetRange) as? ParagraphStyle, - let foundList = paragraphStyle.textList, - foundList == list { - resultRange = resultRange.union(withRange: effectiveRange) + let foundList = paragraphStyle.lists.last + else { + break; + } + if ((listDepth == paragraphStyle.lists.count && foundList == list) || + listDepth < paragraphStyle.lists.count) { + resultRange = resultRange.union(withRange: effectiveRange) } else { break; } } while resultRange.endLocation < self.length { - if + guard let paragraphStyle = attribute(NSParagraphStyleAttributeName, at: resultRange.endLocation, longestEffectiveRange: &effectiveRange, in: targetRange) as? ParagraphStyle, - let foundList = paragraphStyle.textList, - foundList == list { + let foundList = paragraphStyle.lists.last + else { + break; + } + if ((listDepth == paragraphStyle.lists.count && foundList == list) || + listDepth < paragraphStyle.lists.count) { resultRange = resultRange.union(withRange: effectiveRange) } else { break; @@ -123,17 +71,26 @@ extension NSAttributedString /// - Returns: Returns the index within the list. /// func itemNumber(in list: TextList, at location: Int) -> Int { + guard + let paragraphStyle = attribute(NSParagraphStyleAttributeName, at: location, effectiveRange: nil) as? ParagraphStyle + else { + return NSNotFound + } + let listDepth = paragraphStyle.lists.count guard let rangeOfList = range(of:list, at: location) else { return NSNotFound } var numberInList = 1 - let paragraphRanges = self.paragraphRanges(spanningRange: rangeOfList) + let paragraphRanges = self.paragraphRanges(spanning: rangeOfList) - for range in paragraphRanges { - if NSLocationInRange(location, range) { + for (_, enclosingRange) in paragraphRanges { + if NSLocationInRange(location, enclosingRange) { return numberInList } - numberInList += 1 + if let paragraphStyle = attribute(NSParagraphStyleAttributeName, at: enclosingRange.location, effectiveRange: nil) as? ParagraphStyle, + listDepth == paragraphStyle.lists.count { + numberInList += 1 + } } return NSNotFound } @@ -144,47 +101,6 @@ extension NSAttributedString return NSRange(location: 0, length: length) } - - /// Returns the NSRange that contains a specified position. - /// - /// - Parameter atIndex: Text location for which we want the line range. - /// - /// - Returns: The text's line range, at the specified position, if possible. - /// - func rangeOfLine(atIndex index: Int) -> NSRange? { - var range: NSRange? - - foundationString.enumerateSubstrings(in: rangeOfEntireString, options: NSString.EnumerationOptions()) { (substring, substringRange, enclosingRange, stop) in - guard index >= enclosingRange.location && index < NSMaxRange(enclosingRange) else { - return - } - - range = enclosingRange - stop.pointee = true - } - - return range - } - - - /// Return the contents of a TextList following the specified index (inclusive). - /// Used to retrieve list items that need to be renumbered. - /// - /// - Parameter index: An index intersecting a TextList. - /// - /// - Returns: An NSAttributedString optional containing the list from the specified range or nil if no list was found. - /// - func textListContents(followingIndex index: Int) -> NSAttributedString? { - guard let listRange = rangeOfTextList(atIndex: index) else { - return nil - } - - let diff = index - listRange.location - let subRange = NSRange(location: index, length: listRange.length - diff) - return attributedSubstring(from: subRange) - } - - /// Returns the TextList attribute at the specified NSRange, if any. /// /// - Parameter index: The index at which to inspect. @@ -192,7 +108,7 @@ extension NSAttributedString /// - Returns: A TextList optional. /// func textListAttribute(atIndex index: Int) -> TextList? { - return (attribute(NSParagraphStyleAttributeName, at: index, effectiveRange: nil) as? ParagraphStyle)?.textList + return (attribute(NSParagraphStyleAttributeName, at: index, effectiveRange: nil) as? ParagraphStyle)?.lists.last } /// Returns the TextList attribute, assuming that there is one, spanning the specified Range. @@ -201,7 +117,7 @@ extension NSAttributedString /// /// - Returns: A TextList optional. /// - func textListAttribute(spanningRange range: NSRange) -> TextList? { + func textListAttribute(spanning range: NSRange) -> TextList? { // NOTE: // We're using this mechanism, instead of the old fashioned 'attribute:atIndex:effectiveRange:' because // whenever the "next substring" has a different set of attributes, the effective range gets cut, even though @@ -211,7 +127,7 @@ extension NSAttributedString enumerateAttribute(NSParagraphStyleAttributeName, in: range, options: []) { (attribute, range, stop) in if let paragraphStyle = attribute as? ParagraphStyle { - list = paragraphStyle.textList + list = paragraphStyle.lists.last } stop.pointee = true } @@ -219,29 +135,43 @@ extension NSAttributedString return list } + func paragraphRanges(includeParagraphSeparator: Bool = true) -> [NSRange] { + return paragraphRanges(spanning: rangeOfEntireString, includeParagraphSeparator: includeParagraphSeparator) + } + /// Finds the paragraph ranges in the specified string intersecting the specified range. /// /// - Parameters range: The range within the specified string to find paragraphs. /// /// - Returns: An array containing an NSRange for each paragraph intersected by the specified range. /// - func paragraphRanges(spanningRange range: NSRange) -> [NSRange] { + func paragraphRanges(spanning range: NSRange, includeParagraphSeparator: Bool = true) -> [NSRange] { var paragraphRanges = [NSRange]() - let targetRange = rangeOfEntireString + let swiftRange = string.range(fromUTF16NSRange: range) - foundationString.enumerateSubstrings(in: targetRange, options: .byParagraphs) { (substring, substringRange, enclosingRange, stop) in - // Stop if necessary. - if enclosingRange.location >= NSMaxRange(range) { - stop.pointee = true - return - } + string.enumerateSubstrings(in: swiftRange, options: .byParagraphs) { [unowned self] (substring, substringRange, enclosingRange, stop) in + let paragraphRange = includeParagraphSeparator ? enclosingRange : substringRange + paragraphRanges.append(self.string.utf16NSRange(from: paragraphRange)) + } - // Bail early if the paragraph precedes the start of the selection - if NSMaxRange(enclosingRange) <= range.location { - return - } + return paragraphRanges + } + + /// Finds the paragraph ranges in the specified string intersecting the specified range. + /// + /// - Parameters range: The range within the specified string to find paragraphs. + /// + /// - Returns: An array containing an NSRange for each paragraph intersected by the specified range. + /// + func paragraphRanges(spanning range: NSRange) -> ([(NSRange, NSRange)]) { + var paragraphRanges = [(NSRange, NSRange)]() + let swiftRange = string.range(fromUTF16NSRange: range) - paragraphRanges.append(enclosingRange) + string.enumerateSubstrings(in: swiftRange, options: .byParagraphs) { [unowned self] (substring, substringRange, enclosingRange, stop) in + let substringNSRange = self.string.utf16NSRange(from: substringRange) + let enclosingNSRange = self.string.utf16NSRange(from: enclosingRange) + + paragraphRanges.append((substringNSRange, enclosingNSRange)) } return paragraphRanges @@ -252,63 +182,28 @@ extension NSAttributedString /// This is an attributed string wrapper for `NSString.paragraphRangeForRange()` /// func paragraphRange(for range: NSRange) -> NSRange { - return foundationString.paragraphRange(for: range) - } - + let swiftRange = string.range(fromUTF16NSRange: range) + let outRange = string.paragraphRange(for: swiftRange) - /// Returns all of the paragraphs, spanning at the specified index, with the given TextList Kind. - /// - /// - Parameters: - /// - index: The index at which to inspect. - /// - style: The type of TextList. - /// - /// - Return: A NSRange collection containing the paragraphs with the specified TextList Kind. - /// - func paragraphRanges(atIndex index: Int, matchingListStyle style: TextList.Style) -> [NSRange] { - guard index >= 0 && index < length, let range = rangeOfTextList(atIndex: index), - let list = textListAttribute(atIndex: index), list.style == style else - { - return [] - } - - return paragraphRanges(spanningRange: range) + return string.utf16NSRange(from: outRange) } - /// Given a collection of Ranges, this helper will attempt to infer if the previous + following - /// paragraphs contain a TextList, of the specified kind. - /// If so, their ranges will be returned along with the received ranges, in a sorted fashion. + /// Enumerates all of the paragraphs spanning a NSRange /// /// - Parameters: - /// - ranges: Ranges that should be checked - /// - kind: Kind of list to look for - /// - /// - Returns: A collection of sorted NSRange's + /// - range: Range that should be checked for paragraphs + /// - reverseOrder: Boolean indicating if the paragraphs should be enumerated in reverse order + /// - block: Closure to be executed for each paragraph /// - func paragraphRanges(preceedingAndSucceding ranges: [NSRange], matchingListStyle style: TextList.Style) -> [NSRange] { - guard let firstRange = ranges.first, let lastRange = ranges.last else { - return ranges - } - - // Check preceding + following paragraphs style for same kind of list & same list level. - // If found add those paragraph ranges. - let preceedingIndex = firstRange.location - 1 - let followingIndex = NSMaxRange(lastRange) - var adjustedRanges = ranges - - for index in [preceedingIndex, followingIndex] { - for range in paragraphRanges(atIndex: index, matchingListStyle: style) { - guard adjustedRanges.contains(where: { NSEqualRanges($0, range)}) == false else { - continue - } - - adjustedRanges.append(range) - } + func enumerateParagraphRanges(spanning range: NSRange, reverseOrder: Bool = false, using block: ((NSRange, NSRange) -> Void)) { + var ranges = paragraphRanges(spanning: range) + if reverseOrder { + ranges.reverse() } - // Check the ranges are sorted in ascending order - return adjustedRanges.sorted { - $0.location < $1.location + for (range, enclosingRange) in ranges { + block(range, enclosingRange) } } diff --git a/Aztec/Classes/Extensions/NSAttributedString+ReplaceOcurrences.swift b/Aztec/Classes/Extensions/NSAttributedString+ReplaceOcurrences.swift new file mode 100644 index 000000000..b6096047f --- /dev/null +++ b/Aztec/Classes/Extensions/NSAttributedString+ReplaceOcurrences.swift @@ -0,0 +1,19 @@ +import Foundation + +extension NSAttributedString { + + /// Convenience initializer for text replacement in an `NSAttributedString`. + /// + /// - Parameters: + /// - stringToFind: the string to replace. + /// - replacementString: the string to replace all matching occurrences with. + /// + convenience init(with attributedString: NSAttributedString, replacingOcurrencesOf string: String, with replacementString: String) { + + let mutableString = attributedString.mutableCopy() as! NSMutableAttributedString + + mutableString.replaceOcurrences(of: string, with: replacementString) + + self.init(attributedString: mutableString) + } +} diff --git a/Aztec/Classes/Extensions/NSAttributedString+Strip.swift b/Aztec/Classes/Extensions/NSAttributedString+Strip.swift new file mode 100644 index 000000000..4a413e25e --- /dev/null +++ b/Aztec/Classes/Extensions/NSAttributedString+Strip.swift @@ -0,0 +1,28 @@ +import Foundation + + +// MARK: - NSAttributedString: Stripping Attributes +// +extension NSAttributedString { + + /// Removes attributes of the specified Types, and returns a clean copy of the receiver. + /// + func stripAttributes(of kinds: [Any.Type]) -> NSAttributedString { + guard let clean = mutableCopy() as? NSMutableAttributedString else { + fatalError() + } + + let range = clean.rangeOfEntireString + clean.enumerateAttributes(in: range, options: []) { (attributes, range, _) in + for (key, value) in attributes { + guard kinds.contains(where: { type(of: value) == $0 }) else { + continue + } + + clean.removeAttribute(key, range: range) + } + } + + return clean + } +} diff --git a/Aztec/Classes/Extensions/NSLayoutManager+Attachments.swift b/Aztec/Classes/Extensions/NSLayoutManager+Attachments.swift index 65c93d11b..508a0cf7d 100644 --- a/Aztec/Classes/Extensions/NSLayoutManager+Attachments.swift +++ b/Aztec/Classes/Extensions/NSLayoutManager+Attachments.swift @@ -1,15 +1,28 @@ import UIKit -extension NSLayoutManager -{ + +// MARK: - NSLayoutManager Helpers +// +extension NSLayoutManager { + /// Invalidates the layout for an attachment when some change happened to it. - public func invalidateLayoutForAttachment(_ attachment: NSTextAttachment) { + /// + func invalidateLayout(for attachment: NSTextAttachment) { guard let ranges = textStorage?.ranges(forAttachment: attachment) else { return } + for range in ranges { invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) invalidateDisplay(forCharacterRange: range) } } + + /// Ensures the layout for all of the TextContainers. + /// + func ensureLayoutForContainers() { + for textContainer in textContainers { + ensureLayout(for: textContainer) + } + } } diff --git a/Aztec/Classes/Extensions/NSMutableAttributedString+ReplaceOcurrences.swift b/Aztec/Classes/Extensions/NSMutableAttributedString+ReplaceOcurrences.swift new file mode 100644 index 000000000..0f817dedf --- /dev/null +++ b/Aztec/Classes/Extensions/NSMutableAttributedString+ReplaceOcurrences.swift @@ -0,0 +1,47 @@ +import Foundation + +extension NSMutableAttributedString { + + /// Replaces all ocurrences of `stringToFind` with `replacementString` in the receiver. + /// + /// - Parameters: + /// - stringToFind: the string to replace. + /// - replacementString: the string to replace all matching occurrences with. + /// + func replaceOcurrences(of stringToFind: String, with replacementString: String) { + + assert(!replacementString.contains(stringToFind), + "Allowing the replacement string to contain the original string would result in a ininite loop.") + + while let range = string.range(of: stringToFind) { + let nsRange = string.utf16NSRange(from: range) + + replaceCharacters(in: nsRange, with: replacementString) + } + } + + /// Replaces all ocurrences of `stringToFind` with `replacementString` in the receiver. + /// + /// - Parameters: + /// - stringToFind: the string to replace. + /// - replacementString: the string to replace all matching occurrences with. + /// + /// - Returns: the provided range after replacing the occurrences. + /// + func replaceOcurrences(of stringToFind: String, with replacementString: String, within range: NSRange) { + + assert(!replacementString.contains(stringToFind), + "Allowing the replacement string to contain the original string would result in a ininite loop.") + + let swiftUTF16Range = string.utf16.range(from: range) + let swiftRange = string.range(from: swiftUTF16Range) + + while let matchRange = string.range(of: stringToFind, options: [], range: swiftRange, locale: nil) { + let matchNSRange = string.utf16NSRange(from: matchRange) + + replaceCharacters(in: matchNSRange, with: replacementString) + } + + + } +} diff --git a/Aztec/Classes/Extensions/NSRange+Helpers.swift b/Aztec/Classes/Extensions/NSRange+Helpers.swift index 5d3ac9678..b1adfdc49 100644 --- a/Aztec/Classes/Extensions/NSRange+Helpers.swift +++ b/Aztec/Classes/Extensions/NSRange+Helpers.swift @@ -12,9 +12,40 @@ extension NSRange /// /// - Returns: `true` if the receiver contains the specified range, `false` otherwise. /// - func contains(range: NSRange) -> Bool { + func contains(_ range: NSRange) -> Bool { return intersect(withRange: range) == range } + + /// Checks if the receiver contains the specified location. + /// + /// - Parameters: + /// - range: the location that the receiver may or may not contain. + /// + /// - Returns: `true` if the receiver contains the specified location, `false` otherwise. + /// + func contains(offset: Int) -> Bool { + return offset >= location && offset <= location + length + } + + /// Calculates the end location for the receiver. + /// + /// - Returns: the requested end location + /// + var endLocation: Int { + return location + length + } + + /// Returns a range equal to the receiver extended to its right side by the specified addition + /// value. + /// + /// - Parameters: + /// - addition: the number that will be added to the length of the range + /// + /// - Returns: the new range. + /// + func extendedRight(by addition: Int) -> NSRange { + return NSRange(location: location, length: length + addition) + } /// Returns the intersection between the receiver and the specified range. /// @@ -45,6 +76,41 @@ extension NSRange } } + /// Offsets the receiver by the specified value. + /// + /// - Parameters: + /// - offset: the value to apply for the offset operation. + /// + /// - Returns: the requested range. + /// + func offset(by offset: Int) -> NSRange { + return NSRange(location: location + offset, length: length) + } + + /// Returns a range equal to the receiver shortened on its left side by the specified deduction + /// value. + /// + /// - Parameters: + /// - deduction: the number that will be deducted from the length of the range + /// + /// - Returns: the new range. + /// + func shortenedLeft(by deduction: Int) -> NSRange { + return NSRange(location: location + deduction, length: length - deduction) + } + + /// Returns a range equal to the receiver shortened on its right side by the specified deduction + /// value. + /// + /// - Parameters: + /// - deduction: the number that will be deducted from the length of the range + /// + /// - Returns: the new range. + /// + func shortenedRight(by deduction: Int) -> NSRange { + return NSRange(location: location, length: length - deduction) + } + /// Returns the union with the specified range. /// /// This is `NSUnionRange` wrapped as an instance method. @@ -53,12 +119,6 @@ extension NSRange return NSUnionRange(self, target) } - /// Returns the maximum Location. - /// - var endLocation: Int { - return location + length - } - /// Returns a NSRange instance with location = 0 + length = 0 /// static var zero: NSRange { diff --git a/Aztec/Classes/Extensions/NSTextingResult+Helpers.swift b/Aztec/Classes/Extensions/NSTextingResult+Helpers.swift new file mode 100644 index 000000000..5f8b6c4e8 --- /dev/null +++ b/Aztec/Classes/Extensions/NSTextingResult+Helpers.swift @@ -0,0 +1,27 @@ + import Foundation + +public extension NSTextCheckingResult { + + /// Returns the match for the corresponding capture group position in a text + /// + /// - Parameters: + /// - position: the capture group position + /// - text: the string where the match was detected + /// - Returns: the string with the captured group text + /// + func captureGroup(in position: Int, text: String) -> String? { + guard position < numberOfRanges else { + return nil + } + + let nsrange = rangeAt(position) + + guard nsrange.location != NSNotFound else { + return nil + } + + let range = text.range(from: nsrange) + let captureGroup = text.substring(with: range) + return captureGroup + } +} diff --git a/Aztec/Classes/Extensions/String+EndOfLine.swift b/Aztec/Classes/Extensions/String+EndOfLine.swift new file mode 100644 index 000000000..9b9a3a4b4 --- /dev/null +++ b/Aztec/Classes/Extensions/String+EndOfLine.swift @@ -0,0 +1,131 @@ +import Foundation + +extension String { + + /// Checks if the receiver has an empty paragraph at the specified index. + /// + /// - Parameters: + /// - index: the receiver's index to check + /// + /// - Returns: `true` if the specified index is in an empty paragraph, `false` otherwise. + /// + func isEmptyParagraph(at index: String.Index) -> Bool { + return isStartOfNewLine(at: index) && isEndOfLine(at: index) + } + + /// Checks if the receiver has an empty paragraph at the specified offset. + /// + /// - Parameters: + /// - offset: the receiver's offset to check + /// + /// - Returns: `true` if the specified offset is in an empty paragraph, `false` otherwise. + /// + func isEmptyParagraph(at offset: Int) -> Bool { + guard let index = self.indexFromLocation(offset) else { + return true + } + + return isEmptyParagraph(at: index) + } + + /// Checks if the receiver has an empty paragraph at the specified offset and if the offset + /// corresponds to EOF (end-of-file). + /// + /// - Parameters: + /// - offset: the receiver's offset to check + /// + /// - Returns: `true` if the specified offset is in an empty paragraph, `false` otherwise. + /// + func isEmptyParagraphAtEndOfFile(at offset: Int) -> Bool { + return offset == characters.count && isEmptyParagraph(at: offset) + } + + /// This methods verifies if the receiver string is an end-of-line character. + /// + /// - Returns: `true` if the receiver is an end-of-line character. + /// + func isEndOfLine() -> Bool { + return self == String(.carriageReturn) + || self == String(.lineSeparator) + || self == String(.lineFeed) + || self == String(.paragraphSeparator) + } + + func isEndOfLine(after index: String.Index) -> Bool { + assert(index != endIndex) + + let nextIndex = self.index(after: index) + + return isEndOfLine(at: nextIndex) + } + + func isEndOfLine(before index: String.Index) -> Bool { + assert(index != startIndex) + + let previousIndex = self.index(before: index) + + return isEndOfLine(at: previousIndex) + } + + func isEndOfLine(at index: String.Index) -> Bool { + return index == endIndex || substring(with: index ..< self.index(after: index)).isEndOfLine() + } + + func isEndOfLine(atUTF16Offset utf16Offset: Int) -> Bool { + let utf16Index = utf16.index(utf16.startIndex, offsetBy: utf16Offset) + + guard let index = utf16Index.samePosition(in: self) else { + fatalError("This should not be possible, review your logic.") + } + + return isEndOfLine(at: index) + } + + /// Checks if the location passed is the beggining of a new line. + /// + /// - Parameters: + /// - index: the index to check + /// + /// - Returns: true if beggining of a new line false otherwise + /// + func isStartOfNewLine(at index: String.Index) -> Bool { + + guard index != startIndex else { + return true + } + + return isEndOfLine(before: index) + } + + /// Checks if the location passed is the beggining of a new line. + /// + /// - Parameters: + /// - offset: the receiver's offset to check + /// + /// - Returns: true if beggining of a new line false otherwise + /// + func isStartOfNewLine(at offset: Int) -> Bool { + + let index = self.index(startIndex, offsetBy: offset) + + return isStartOfNewLine(at: index) + } + + /// Checks if the location passed is the beggining of a new line. + /// + /// - Parameters: + /// - offset: the receiver's offset to check + /// + /// - Returns: true if beggining of a new line false otherwise + /// + func isStartOfNewLine(atUTF16Offset utf16Offset: Int) -> Bool { + + let utf16Index = utf16.index(utf16.startIndex, offsetBy: utf16Offset) + + guard let index = utf16Index.samePosition(in: self) else { + fatalError("This should not be possible, review your logic.") + } + + return isStartOfNewLine(at: index) + } +} diff --git a/Aztec/Classes/Extensions/String+HTML.swift b/Aztec/Classes/Extensions/String+HTML.swift new file mode 100644 index 000000000..fa0d21984 --- /dev/null +++ b/Aztec/Classes/Extensions/String+HTML.swift @@ -0,0 +1,29 @@ +import Foundation + + +// MARK: - String HTML Extensions +// +extension String { + + /// Encodes all of the HTML Entities: Unicode Characters will be expressed as hexadecimal. + /// Named Entities will also be replaced, whenever `allowNamedEntities` is set to `true`. + /// + public func encodeHtmlEntities(allowNamedEntities: Bool = true) -> String { + let theString = allowNamedEntities ? escapeHtmlNamedEntities() : self + + return theString.unicodeScalars.reduce("") { (out: String, char: UnicodeScalar) in + let encoded = char.isASCII ? char.description: String(format: "&#x%2X;", char.value) + return out + encoded + } + } + + /// Escapes the following HTML entities: [&, <, >, ', "] + /// + private func escapeHtmlNamedEntities() -> String { + return replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "\'", with: "'") + } +} diff --git a/Aztec/Classes/Extensions/String+RangeConversion.swift b/Aztec/Classes/Extensions/String+RangeConversion.swift index 508f36066..cb396e817 100644 --- a/Aztec/Classes/Extensions/String+RangeConversion.swift +++ b/Aztec/Classes/Extensions/String+RangeConversion.swift @@ -3,26 +3,162 @@ import Foundation // MARK: - String NSRange and Location convertion Extensions // -extension String -{ - func rangeFromNSRange(_ nsRange : NSRange) -> Range? { - let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location) - let to16 = utf16.index(from16, offsetBy: nsRange.length) +public extension String +{ + /// Converts a UTF16 NSRange into a Swift String NSRange for this string. + /// + /// - Parameters: + /// - nsRange: the UTF16 NSRange to convert. + /// + /// - Returns: the requested `Swift String NSRange` + /// + func nsRange(fromUTF16NSRange nsRange: NSRange) -> NSRange? { - guard - let from = from16.samePosition(in: self), - let to = to16.samePosition(in: self) - else { return nil } - return from ..< to - + let utf16Range = utf16.range(from: nsRange) + + guard let range = range(from: utf16Range) else { + return nil + } + + let location = distance(from: startIndex, to: range.lowerBound) + let length = distance(from: range.lowerBound, to: range.upperBound) + + return NSRange(location: location, length: length) + } + + /// Converts a Swift String NSRange into a UTF16 NSRange for this string. + /// + /// - Parameters: + /// - nsRange: the Swift String NSRange to convert. + /// + /// - Returns: the requested `UTF16 NSRange` + /// + func utf16NSRange(from nsRange: NSRange) -> NSRange { + let swiftRange = range(from: nsRange) + let utf16NSRange = self.utf16NSRange(from: swiftRange) + + return utf16NSRange + } + + /// Converts an NSRange into a `Range` for this string. + /// + /// - Parameters: + /// - nsRange: the NSRange to convert. + /// + /// - Returns: the requested `Range` + /// + func range(from nsRange: NSRange) -> Range { + let lowerBound = index(startIndex, offsetBy: nsRange.location) + let upperBound = index(lowerBound, offsetBy: nsRange.length) + + return lowerBound ..< upperBound + } + + func range(fromUTF16NSRange utf16NSRange: NSRange) -> Range { + + let swiftUTF16Range = utf16.range(from: utf16NSRange) + + guard let swiftRange = range(from: swiftUTF16Range) else { + fatalError("Out of bounds!") + } + + return swiftRange + } + + /// Converts a UTF16 NSRange into a `Range` for this string. + /// + /// - Parameters: + /// - nsRange: the UTF16 NSRange to convert. + /// + /// - Returns: the requested `Range` + /// + func range(from utf16Range: Range) -> Range? { + guard let start = utf16Range.lowerBound.samePosition(in: self), + let end = utf16Range.upperBound.samePosition(in: self) else { + return nil + } + + return start ..< end + } + + func range(from unicodeNSRange: Range) -> Range? { + guard let lowerBound = unicodeNSRange.lowerBound.samePosition(in: self), + let upperBound = unicodeNSRange.upperBound.samePosition(in: self) else { + return nil + } + + return lowerBound ..< upperBound + } + + func nsRange(of string: String) -> NSRange? { + guard let range = self.range(of: string) else { + return nil + } + + return nsRange(from: range) + } + + /// Converts a `Range` into an UTF16 NSRange. + /// + /// - Parameters: + /// - range: the range to convert. + /// + /// - Returns: the requested `NSRange`. + /// + func nsRange(from range: Range) -> NSRange { + + let location = distance(from: startIndex, to: range.lowerBound) + let length = distance(from: range.lowerBound, to: range.upperBound) + + return NSRange(location: location, length: length) + } + + /// Converts a `Range` into an UTF16 NSRange. + /// + /// - Parameters: + /// - range: the range to convert. + /// + /// - Returns: the requested `NSRange`. + /// + func utf16NSRange(from range: Range) -> NSRange { + + let lowerBound = range.lowerBound.samePosition(in: utf16) + let upperBound = range.upperBound.samePosition(in: utf16) + + let location = utf16.distance(from: utf16.startIndex, to: lowerBound) + let length = utf16.distance(from: lowerBound, to: upperBound) + + return NSRange(location: location, length: length) + } + + /// Converts a `Range` into an Unicod Scalar `NSRange`. + /// + /// - Parameters: + /// - range: the range to convert. + /// + /// - Returns: the requested `NSRange`. + /// + func nsRange(from range: Range) -> NSRange { + let location = unicodeScalars.distance(from: unicodeScalars.startIndex, to: range.lowerBound) + let length = unicodeScalars.distance(from: range.lowerBound, to: range.upperBound) + + return NSRange(location: location, length: length) + } + + /// Returns a NSRange with a starting location at the very end of the string + /// + func endOfStringNSRange() -> NSRange { + return NSRange(location: characters.count, length: 0) } func indexFromLocation(_ location: Int) -> String.Index? { guard - let from16 = utf16.index(utf16.startIndex, offsetBy: location, limitedBy: utf16.endIndex), - let from = from16.samePosition(in: self) - else { return nil } - return from + let unicodeLocation = utf16.index(utf16.startIndex, offsetBy: location, limitedBy: utf16.endIndex), + let location = unicodeLocation.samePosition(in: self) else { + return nil + } + + return location } func isLastValidLocation(_ location: Int) -> Bool { @@ -50,4 +186,12 @@ extension String let before16 = beforeIndex.samePosition(in: utf16) return utf16.distance(from: utf16.startIndex, to: before16) } + + func range(_ range: Range, offsetBy offset: String.IndexDistance) -> Range { + + let startIndex = index(range.lowerBound, offsetBy: offset) + let endIndex = index(range.upperBound, offsetBy: offset) + + return startIndex ..< endIndex + } } diff --git a/Aztec/Classes/Extensions/StringUTF16+RangeConversion.swift b/Aztec/Classes/Extensions/StringUTF16+RangeConversion.swift new file mode 100644 index 000000000..87e69f14a --- /dev/null +++ b/Aztec/Classes/Extensions/StringUTF16+RangeConversion.swift @@ -0,0 +1,18 @@ +import Foundation + +extension String.UTF16View { + + /// Converts a UTF16 `NSRange` into a `Range` for this string. + /// + /// - Parameters: + /// - nsRange: the UTF16 NSRange to convert. + /// + /// - Returns: the requested `Range` or `nil` if the conversion fails. + /// + func range(from nsRange : NSRange) -> Range { + let start = index(startIndex, offsetBy: nsRange.location) + let end = index(start, offsetBy: nsRange.length) + + return start ..< end + } +} diff --git a/Aztec/Classes/Extensions/StringUnicodeScalarView+RangeConversion.swift b/Aztec/Classes/Extensions/StringUnicodeScalarView+RangeConversion.swift new file mode 100644 index 000000000..d3b6da73b --- /dev/null +++ b/Aztec/Classes/Extensions/StringUnicodeScalarView+RangeConversion.swift @@ -0,0 +1,19 @@ +import Foundation + +extension String.UnicodeScalarView { + + /// Converts a Unicode Scalar `NSRange` into a `Range` + /// for this string. + /// + /// - Parameters: + /// - nsRange: the range to convert. + /// + /// - Returns: the requested `Range` + /// + func range(from nsRange : NSRange) -> Range? { + let start = index(startIndex, offsetBy: nsRange.location) + let end = index(start, offsetBy: nsRange.length) + + return start ..< end + } +} diff --git a/Aztec/Classes/Extensions/UIFont+Emoji.swift b/Aztec/Classes/Extensions/UIFont+Emoji.swift index d1125b835..bc43d3704 100644 --- a/Aztec/Classes/Extensions/UIFont+Emoji.swift +++ b/Aztec/Classes/Extensions/UIFont+Emoji.swift @@ -9,6 +9,6 @@ extension UIFont { /// Indicates if the current font instance matches with iOS's Internal Emoji Font, or not. /// var isAppleEmojiFont: Bool { - return fontName == ".AppleColorEmojiUI" + return fontName == ".AppleColorEmojiUI" || fontName == "AppleColorEmoji" } } diff --git a/Aztec/Classes/Formatters/AttributeFormatter.swift b/Aztec/Classes/Formatters/Base/AttributeFormatter.swift similarity index 78% rename from Aztec/Classes/Formatters/AttributeFormatter.swift rename to Aztec/Classes/Formatters/Base/AttributeFormatter.swift index 0cd0abb76..3e0e0be9d 100644 --- a/Aztec/Classes/Formatters/AttributeFormatter.swift +++ b/Aztec/Classes/Formatters/Base/AttributeFormatter.swift @@ -42,6 +42,17 @@ protocol AttributeFormatter { /// func apply(to attributes: [String: Any]) -> [String: Any] + /// Apply the compound attributes to the provided attributes dictionary. + /// + /// - Parameters: + /// - attributes: the original attributes to apply to + /// - representation: the original HTML representation for the attribute to apply. + /// + /// - Returns: + /// - the resulting attributes dictionary + /// + func apply(to attributes: [String: Any], andStore representation: HTMLRepresentation?) -> [String: Any] + /// Remove the compound attributes from the provided list. /// /// - Parameter attributes: the original attributes to remove from @@ -71,6 +82,13 @@ protocol AttributeFormatter { // extension AttributeFormatter { + /// The default implementation forwards the call. This is probably good enough for all + /// classes that implement this protocol. + /// + func apply(to attributes: [String : Any]) -> [String: Any] { + return apply(to: attributes, andStore: nil) + } + /// Indicates whether the Formatter's Attributes are present in a given string, at a specified Index. /// func present(in text: NSAttributedString, at index: Int) -> Bool { @@ -103,7 +121,8 @@ extension AttributeFormatter { return result && enumerateAtLeastOnce } - @discardableResult func toggle(in attributes: [String: Any]) -> [String: Any] { + @discardableResult + func toggle(in attributes: [String: Any]) -> [String: Any] { if present(in: attributes) { return remove(from: attributes) } else { @@ -115,16 +134,11 @@ extension AttributeFormatter { /// /// - Returns: the full range where the attributes where applied /// - @discardableResult func applyAttributes(to text: NSMutableAttributedString, at range: NSRange) -> NSRange { - var rangeToApply = applicationRange(for: range, in: text) - - if worksInEmptyRange() && ( rangeToApply.length == 0 || text.length == 0) { - let placeholder = placeholderForEmptyLine(using: placeholderAttributes) - text.insert(placeholder, at: rangeToApply.location) - rangeToApply = NSMakeRange(rangeToApply.location, placeholder.length) - } + @discardableResult + func applyAttributes(to text: NSMutableAttributedString, at range: NSRange) -> NSRange { + let rangeToApply = applicationRange(for: range, in: text) - text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, stop) in + text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, _) in let currentAttributes = text.attributes(at: range.location, effectiveRange: nil) let attributes = apply(to: currentAttributes) text.addAttributes(attributes, range: range) @@ -137,7 +151,8 @@ extension AttributeFormatter { /// /// - Returns: the full range where the attributes where removed /// - @discardableResult func removeAttributes(from text: NSMutableAttributedString, at range: NSRange) -> NSRange { + @discardableResult + func removeAttributes(from text: NSMutableAttributedString, at range: NSRange) -> NSRange { let rangeToApply = applicationRange(for: range, in: text) text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, stop) in let currentAttributes = text.attributes(at: range.location, effectiveRange: nil) @@ -160,7 +175,7 @@ extension AttributeFormatter { @discardableResult func toggle(in text: NSMutableAttributedString, at range: NSRange) -> NSRange { //We decide if we need to apply or not the attribute based on the value on the initial position of the range - let shouldApply = shouldApplyAttributes(to: text, at: range) + let shouldApply = shouldApplyAttributes(to: text, at: range) if shouldApply { return applyAttributes(to: text, at: range) @@ -175,12 +190,6 @@ extension AttributeFormatter { // private extension AttributeFormatter { - /// The string to be used when adding attributes to an empty line. - /// - func placeholderForEmptyLine(using attributes: [String: Any]?) -> NSAttributedString { - return VisualOnlyElementFactory().zeroWidthSpace(inheritingAttributes: attributes) - } - /// Helper that indicates whether if we should format the specified range, or not. /// - Note: For convenience reasons, whenever the Text is empty, this helper will return *true*. /// @@ -192,39 +201,3 @@ private extension AttributeFormatter { return present(in: text, at: range) == false } } - - -// MARK: - Character Attribute Formatter -// -protocol CharacterAttributeFormatter: AttributeFormatter { -} - -extension CharacterAttributeFormatter { - - var placeholderAttributes: [String : Any]? { return nil } - - func applicationRange(for range: NSRange, in text: NSAttributedString) -> NSRange { - return range - } - - func worksInEmptyRange() -> Bool { - return false - } -} - - -// MARK: - Paragraph Attribute Formatter -// -protocol ParagraphAttributeFormatter: AttributeFormatter { -} - -extension ParagraphAttributeFormatter { - - func applicationRange(for range: NSRange, in text: NSAttributedString) -> NSRange { - return text.paragraphRange(for: range) - } - - func worksInEmptyRange() -> Bool { - return true - } -} diff --git a/Aztec/Classes/Formatters/FontFormatter.swift b/Aztec/Classes/Formatters/Base/FontFormatter.swift similarity index 61% rename from Aztec/Classes/Formatters/FontFormatter.swift rename to Aztec/Classes/Formatters/Base/FontFormatter.swift index 8d622fadb..81ccda35a 100644 --- a/Aztec/Classes/Formatters/FontFormatter.swift +++ b/Aztec/Classes/Formatters/Base/FontFormatter.swift @@ -1,27 +1,39 @@ import Foundation import UIKit -class FontFormatter: CharacterAttributeFormatter { +class FontFormatter: AttributeFormatter { - let elementType: Libxml2.StandardElementType + var placeholderAttributes: [String : Any]? { return nil } + let htmlRepresentationKey: String let traits: UIFontDescriptorSymbolicTraits - init(elementType: Libxml2.StandardElementType, traits: UIFontDescriptorSymbolicTraits) { - self.elementType = elementType + init(traits: UIFontDescriptorSymbolicTraits, htmlRepresentationKey: String) { + self.htmlRepresentationKey = htmlRepresentationKey self.traits = traits + } + func applicationRange(for range: NSRange, in text: NSAttributedString) -> NSRange { + return range } - func apply(to attributes: [String : Any]) -> [String: Any] { + func worksInEmptyRange() -> Bool { + return false + } + + func apply(to attributes: [String : Any], andStore representation: HTMLRepresentation?) -> [String: Any] { - var resultingAttributes = attributes guard let font = attributes[NSFontAttributeName] as? UIFont else { return attributes } + let newFont = font.modifyTraits(traits, enable: true) + + var resultingAttributes = attributes + resultingAttributes[NSFontAttributeName] = newFont - + resultingAttributes[htmlRepresentationKey] = representation + return resultingAttributes } @@ -33,6 +45,8 @@ class FontFormatter: CharacterAttributeFormatter { let newFont = font.modifyTraits(traits, enable: false) resultingAttributes[NSFontAttributeName] = newFont + + resultingAttributes.removeValue(forKey: htmlRepresentationKey) return resultingAttributes } @@ -46,17 +60,3 @@ class FontFormatter: CharacterAttributeFormatter { } } -class BoldFormatter: FontFormatter { - - init() { - super.init(elementType: .strong, traits: .traitBold) - } -} - -class ItalicFormatter: FontFormatter { - - init() { - super.init(elementType: .em, traits: .traitItalic) - } -} - diff --git a/Aztec/Classes/Formatters/Base/ParagraphAttributeFormatter.swift b/Aztec/Classes/Formatters/Base/ParagraphAttributeFormatter.swift new file mode 100644 index 000000000..5e06edb6f --- /dev/null +++ b/Aztec/Classes/Formatters/Base/ParagraphAttributeFormatter.swift @@ -0,0 +1,76 @@ +import UIKit + +protocol ParagraphAttributeFormatter: AttributeFormatter { + func apply(to attributes: [String: Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] +} + +extension ParagraphAttributeFormatter { + + func apply(to attributes: [String: Any], andStore representation: HTMLRepresentation?) -> [String: Any] { + + // TODO: this should be changed so that the method signature requires this, but in order to + // do so we need a reengineering of the code that would be too big to tackle now. + // + guard representation is HTMLElementRepresentation || representation == nil else { + fatalError("Never pass anything other than an element representation to a paragraph style.") + } + + let elementRepresentation = representation as? HTMLElementRepresentation + + return apply(to: attributes, andStore: elementRepresentation) + } + + func applicationRange(for range: NSRange, in text: NSAttributedString) -> NSRange { + return text.paragraphRange(for: range) + } + + /// Applies the Formatter's Attributes into a given string, at the specified range. + /// + /// - Returns: the full range where the attributes where applied + /// + @discardableResult + func applyAttributes(to text: NSMutableAttributedString, at range: NSRange) -> NSRange { + let rangeToApply = applicationRange(for: range, in: text) + + text.replaceOcurrences(of: String(.lineFeed), with: String(.paragraphSeparator), within: rangeToApply) + + text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, _) in + let currentAttributes = text.attributes(at: range.location, effectiveRange: nil) + let attributes = apply(to: currentAttributes) + text.addAttributes(attributes, range: range) + } + + return rangeToApply + } + + /// Removes the Formatter's Attributes from a given string, at the specified range. + /// + /// - Returns: the full range where the attributes where removed + /// + @discardableResult + func removeAttributes(from text: NSMutableAttributedString, at range: NSRange) -> NSRange { + let rangeToApply = applicationRange(for: range, in: text) + + text.replaceOcurrences(of: String(.paragraphSeparator), with: String(.lineFeed), within: rangeToApply) + + text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, stop) in + let currentAttributes = text.attributes(at: range.location, effectiveRange: nil) + let attributes = remove(from: currentAttributes) + + let currentKeys = Set(currentAttributes.keys) + let newKeys = Set(attributes.keys) + let removedKeys = currentKeys.subtracting(newKeys) + for key in removedKeys { + text.removeAttribute(key, range: range) + } + + text.addAttributes(attributes, range: range) + } + + return rangeToApply + } + + func worksInEmptyRange() -> Bool { + return true + } +} diff --git a/Aztec/Classes/Formatters/Base/StandardAttributeFormatter.swift b/Aztec/Classes/Formatters/Base/StandardAttributeFormatter.swift new file mode 100644 index 000000000..6bfc34f4c --- /dev/null +++ b/Aztec/Classes/Formatters/Base/StandardAttributeFormatter.swift @@ -0,0 +1,53 @@ +import Foundation +import UIKit + +/// Formatter to apply simple value (NSNumber, UIColor) attributes to an attributed string. +class StandardAttributeFormatter: AttributeFormatter { + + var placeholderAttributes: [String : Any]? { return nil } + + let attributeKey: String + var attributeValue: Any + + let htmlRepresentationKey: String + + // MARK: - Init + + init(attributeKey: String, attributeValue: Any, htmlRepresentationKey: String) { + self.attributeKey = attributeKey + self.attributeValue = attributeValue + self.htmlRepresentationKey = htmlRepresentationKey + } + + func applicationRange(for range: NSRange, in text: NSAttributedString) -> NSRange { + return range + } + + func worksInEmptyRange() -> Bool { + return false + } + + func apply(to attributes: [String : Any], andStore representation: HTMLRepresentation?) -> [String: Any] { + var resultingAttributes = attributes + + resultingAttributes[attributeKey] = attributeValue + resultingAttributes[htmlRepresentationKey] = representation + + return resultingAttributes + } + + func remove(from attributes: [String : Any]) -> [String: Any] { + var resultingAttributes = attributes + + resultingAttributes.removeValue(forKey: attributeKey) + resultingAttributes.removeValue(forKey: htmlRepresentationKey) + + return resultingAttributes + } + + func present(in attributes: [String : Any]) -> Bool { + let enabled = attributes[attributeKey] != nil + return enabled + } +} + diff --git a/Aztec/Classes/Formatters/BlockquoteFormatter.swift b/Aztec/Classes/Formatters/BlockquoteFormatter.swift deleted file mode 100644 index f08c67bcc..000000000 --- a/Aztec/Classes/Formatters/BlockquoteFormatter.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation -import UIKit - -class BlockquoteFormatter: ParagraphAttributeFormatter { - - let elementType: Libxml2.StandardElementType = .blockquote - - let placeholderAttributes: [String : Any]? - - init(placeholderAttributes: [String : Any]? = nil) { - self.placeholderAttributes = placeholderAttributes - } - - func apply(to attributes: [String : Any]) -> [String: Any] { - var resultingAttributes = attributes - let newParagraphStyle = ParagraphStyle() - if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { - newParagraphStyle.setParagraphStyle(paragraphStyle) - } - if newParagraphStyle.blockquote == nil { - newParagraphStyle.headIndent += Metrics.defaultIndentation - newParagraphStyle.firstLineHeadIndent = newParagraphStyle.headIndent - newParagraphStyle.tailIndent -= Metrics.defaultIndentation - newParagraphStyle.paragraphSpacing += Metrics.defaultIndentation - newParagraphStyle.paragraphSpacingBefore += Metrics.defaultIndentation - } - newParagraphStyle.blockquote = Blockquote() - resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle - return resultingAttributes - } - - func remove(from attributes:[String: Any]) -> [String: Any] { - var resultingAttributes = attributes - let newParagraphStyle = ParagraphStyle() - guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, - paragraphStyle.blockquote != nil else { - return resultingAttributes - } - newParagraphStyle.setParagraphStyle(paragraphStyle) - if newParagraphStyle.blockquote != nil { - newParagraphStyle.headIndent -= Metrics.defaultIndentation - newParagraphStyle.firstLineHeadIndent = newParagraphStyle.headIndent - newParagraphStyle.tailIndent += Metrics.defaultIndentation - newParagraphStyle.paragraphSpacing -= Metrics.defaultIndentation - newParagraphStyle.paragraphSpacingBefore -= Metrics.defaultIndentation - } - newParagraphStyle.blockquote = nil - resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle - return resultingAttributes - } - - func present(in attributes: [String : Any]) -> Bool { - if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle { - return paragraphStyle.blockquote != nil - } - return false - } -} - diff --git a/Aztec/Classes/Formatters/HeaderFormatter.swift b/Aztec/Classes/Formatters/HeaderFormatter.swift deleted file mode 100644 index a08db0cbe..000000000 --- a/Aztec/Classes/Formatters/HeaderFormatter.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import UIKit - -open class HeaderFormatter: ParagraphAttributeFormatter { - - public enum HeaderType: Int { - case none = 0 - case h1 = 1 - case h2 = 2 - case h3 = 3 - case h4 = 4 - case h5 = 5 - case h6 = 6 - - public var fontSize: CGFloat { - switch self { - case .none: return 14 - case .h1: return 36 - case .h2: return 24 - case .h3: return 21 - case .h4: return 16 - case .h5: return 14 - case .h6: return 11 - } - } - - public var description: String { - switch self { - case .none: return "Paragraph" - case .h1: return "Heading 1" - case .h2: return "Heading 2" - case .h3: return "Heading 3" - case .h4: return "Heading 4" - case .h5: return "Heading 5" - case .h6: return "Heading 6" - } - } - } - - let headerLevel: HeaderType - - let placeholderAttributes: [String : Any]? - - init(headerLevel: HeaderType = .h1, placeholderAttributes: [String : Any]? = nil) { - self.headerLevel = headerLevel - self.placeholderAttributes = placeholderAttributes - } - - func apply(to attributes: [String : Any]) -> [String: Any] { - var resultingAttributes = attributes - let newParagraphStyle = ParagraphStyle() - if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { - newParagraphStyle.setParagraphStyle(paragraphStyle) - } - if newParagraphStyle.headerLevel == .none && headerLevel != .none { - newParagraphStyle.paragraphSpacing += Metrics.defaultIndentation - newParagraphStyle.paragraphSpacingBefore += Metrics.defaultIndentation - } - newParagraphStyle.headerLevel = headerLevel.rawValue - - resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle - - if let font = attributes[NSFontAttributeName] as? UIFont { - let newFont = font.withSize(headerLevel.fontSize) - resultingAttributes[NSFontAttributeName] = newFont - } - - return resultingAttributes - } - - func remove(from attributes:[String: Any]) -> [String: Any] { - var resultingAttributes = attributes - let newParagraphStyle = ParagraphStyle() - guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, - paragraphStyle.headerLevel != 0 else { - return resultingAttributes - } - newParagraphStyle.setParagraphStyle(paragraphStyle) - if newParagraphStyle.headerLevel != .none && headerLevel == .none { - newParagraphStyle.paragraphSpacing -= Metrics.defaultIndentation - newParagraphStyle.paragraphSpacingBefore -= Metrics.defaultIndentation - } - newParagraphStyle.headerLevel = HeaderType.none.rawValue - resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle - - if let font = attributes[NSFontAttributeName] as? UIFont { - let newFont = font.withSize(HeaderType.none.fontSize) - resultingAttributes[NSFontAttributeName] = newFont - } - - return resultingAttributes - } - - func present(in attributes: [String : Any]) -> Bool { - if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle { - return paragraphStyle.headerLevel == headerLevel.rawValue - } - return false - } -} - diff --git a/Aztec/Classes/Formatters/Implementations/BlockquoteFormatter.swift b/Aztec/Classes/Formatters/Implementations/BlockquoteFormatter.swift new file mode 100644 index 000000000..4c4f348eb --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/BlockquoteFormatter.swift @@ -0,0 +1,59 @@ +import Foundation +import UIKit + + +// MARK: - Blockquote Formatter +// +class BlockquoteFormatter: ParagraphAttributeFormatter { + + /// Attributes to be added by default + /// + let placeholderAttributes: [String : Any]? + + + /// Designated Initializer + /// + init(placeholderAttributes: [String : Any]? = nil) { + self.placeholderAttributes = placeholderAttributes + } + + + // MARK: - Overwriten Methods + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + let newParagraphStyle = ParagraphStyle() + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { + newParagraphStyle.setParagraphStyle(paragraphStyle) + } + + newParagraphStyle.add(property: Blockquote(with: representation)) + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes + } + + func remove(from attributes:[String: Any]) -> [String: Any] { + guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, + !paragraphStyle.blockquotes.isEmpty + else { + return attributes + } + + let newParagraphStyle = ParagraphStyle() + newParagraphStyle.setParagraphStyle(paragraphStyle) + newParagraphStyle.removeProperty(ofType: Blockquote.self) + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes + } + + func present(in attributes: [String : Any]) -> Bool { + guard let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle else { + return false + } + return !style.blockquotes.isEmpty + } +} + diff --git a/Aztec/Classes/Formatters/Implementations/BoldFormatter.swift b/Aztec/Classes/Formatters/Implementations/BoldFormatter.swift new file mode 100644 index 000000000..48aa281e7 --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/BoldFormatter.swift @@ -0,0 +1,9 @@ +import UIKit + +class BoldFormatter: FontFormatter { + static let htmlRepresentationKey = "Bold.htmlRepresentation" + + init() { + super.init(traits: .traitBold, htmlRepresentationKey: BoldFormatter.htmlRepresentationKey) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/ColorFormatter.swift b/Aztec/Classes/Formatters/Implementations/ColorFormatter.swift new file mode 100644 index 000000000..5ee0c0d0d --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/ColorFormatter.swift @@ -0,0 +1,11 @@ +import UIKit + +class ColorFormatter: StandardAttributeFormatter { + static let htmlRepresentationKey = "Color.htmlRepresentation" + + init(color: UIColor = .black) { + super.init(attributeKey: NSForegroundColorAttributeName, + attributeValue: color, + htmlRepresentationKey: ColorFormatter.htmlRepresentationKey) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/HRFormatter.swift b/Aztec/Classes/Formatters/Implementations/HRFormatter.swift new file mode 100644 index 000000000..5f032ccc6 --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/HRFormatter.swift @@ -0,0 +1,11 @@ +import UIKit + +class HRFormatter: StandardAttributeFormatter { + static let htmlRepresentationKey = "HR.htmlRepresentation" + + init() { + super.init(attributeKey: NSAttachmentAttributeName, + attributeValue: LineAttachment(), + htmlRepresentationKey: HRFormatter.htmlRepresentationKey) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/HTMLParagraphFormatter.swift b/Aztec/Classes/Formatters/Implementations/HTMLParagraphFormatter.swift new file mode 100644 index 000000000..6aba383db --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/HTMLParagraphFormatter.swift @@ -0,0 +1,60 @@ +import Foundation +import UIKit + + +// MARK: - Blockquote Formatter +// +class HTMLParagraphFormatter: ParagraphAttributeFormatter { + + /// Attributes to be added by default + /// + let placeholderAttributes: [String : Any]? + + + /// Designated Initializer + /// + init(placeholderAttributes: [String : Any]? = nil) { + self.placeholderAttributes = placeholderAttributes + } + + + // MARK: - Overwriten Methods + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + let newParagraphStyle = ParagraphStyle() + + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { + newParagraphStyle.setParagraphStyle(paragraphStyle) + } + + newParagraphStyle.add(property: HTMLParagraph(with: representation)) + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes + } + + func remove(from attributes:[String: Any]) -> [String: Any] { + guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, + !paragraphStyle.htmlParagraph.isEmpty + else { + return attributes + } + + let newParagraphStyle = ParagraphStyle() + newParagraphStyle.setParagraphStyle(paragraphStyle) + newParagraphStyle.removeProperty(ofType: HTMLParagraph.self) + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes + } + + func present(in attributes: [String : Any]) -> Bool { + guard let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle else { + return false + } + return !style.htmlParagraph.isEmpty + } +} + diff --git a/Aztec/Classes/Formatters/Implementations/HeaderFormatter.swift b/Aztec/Classes/Formatters/Implementations/HeaderFormatter.swift new file mode 100644 index 000000000..ea2fe1931 --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/HeaderFormatter.swift @@ -0,0 +1,78 @@ +import Foundation +import UIKit + + +// MARK: - Header Formatter +// +open class HeaderFormatter: ParagraphAttributeFormatter { + + /// Heading Level of this formatter + /// + let headerLevel: Header.HeaderType + + /// Attributes to be added by default + /// + let placeholderAttributes: [String : Any]? + + + /// Designated Initializer + /// + init(headerLevel: Header.HeaderType = .h1, placeholderAttributes: [String : Any]? = nil) { + self.headerLevel = headerLevel + self.placeholderAttributes = placeholderAttributes + } + + + // MARK: - Overwriten Methods + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + var resultingAttributes = attributes + let newParagraphStyle = ParagraphStyle() + + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { + newParagraphStyle.setParagraphStyle(paragraphStyle) + } + + if (newParagraphStyle.headerLevel == 0) { + newParagraphStyle.add(property: Header(level: headerLevel, with: representation)) + } else { + newParagraphStyle.replaceProperty(ofType: Header.self, with: Header(level: headerLevel)) + } + + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + + if let font = attributes[NSFontAttributeName] as? UIFont { + let newFont = font.withSize(headerLevel.fontSize) + resultingAttributes[NSFontAttributeName] = newFont + } + + return resultingAttributes + } + + func remove(from attributes:[String: Any]) -> [String: Any] { + var resultingAttributes = attributes + let newParagraphStyle = ParagraphStyle() + guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, + paragraphStyle.headerLevel != 0 else { + return resultingAttributes + } + newParagraphStyle.setParagraphStyle(paragraphStyle) + newParagraphStyle.removeProperty(ofType: Header.self) + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + + if let font = attributes[NSFontAttributeName] as? UIFont { + let newFont = font.withSize(Header.HeaderType.none.fontSize) + resultingAttributes[NSFontAttributeName] = newFont + } + + return resultingAttributes + } + + func present(in attributes: [String : Any]) -> Bool { + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle { + return paragraphStyle.headerLevel != 0 && paragraphStyle.headerLevel == headerLevel.rawValue + } + return false + } +} + diff --git a/Aztec/Classes/Formatters/Implementations/ImageFormatter.swift b/Aztec/Classes/Formatters/Implementations/ImageFormatter.swift new file mode 100644 index 000000000..4836af1ad --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/ImageFormatter.swift @@ -0,0 +1,55 @@ +import UIKit + +class ImageFormatter: StandardAttributeFormatter { + static let htmlRepresentationKey = "Image.htmlRepresentation" + + init() { + super.init( + attributeKey: NSAttachmentAttributeName, + attributeValue: ImageAttachment(identifier: NSUUID().uuidString), + htmlRepresentationKey: ImageFormatter.htmlRepresentationKey) + } + + override func apply(to attributes: [String : Any], andStore representation: HTMLRepresentation?) -> [String: Any] { + let elementRepresentation = representation as? HTMLElementRepresentation + + guard representation == nil || elementRepresentation != nil else { + fatalError("This should not be possible. Review the logic") + } + + return apply(to: attributes, andStore: elementRepresentation) + } + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + + if let representation = representation { + let url: URL? + + if let urlString = representation.valueForAttribute(named: "src") { + url = URL(string: urlString) + } else { + url = nil + } + + let attachment = ImageAttachment(identifier: UUID().uuidString, url: url) + + if let elementClass = representation.valueForAttribute(named: "class") { + let classAttributes = elementClass.components(separatedBy: " ") + for classAttribute in classAttributes { + if let alignment = ImageAttachment.Alignment.fromHTML(string: classAttribute) { + attachment.alignment = alignment + } + if let size = ImageAttachment.Size.fromHTML(string: classAttribute) { + attachment.size = size + } + } + } + + attributeValue = attachment + } else { + attributeValue = ImageAttachment(identifier: UUID().uuidString) + } + + return super.apply(to: attributes, andStore: representation) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/ItalicFormatter.swift b/Aztec/Classes/Formatters/Implementations/ItalicFormatter.swift new file mode 100644 index 000000000..735d2e39d --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/ItalicFormatter.swift @@ -0,0 +1,9 @@ +import UIKit + +class ItalicFormatter: FontFormatter { + static let htmlRepresentationKey = "Italic.htmlRepresentation" + + init() { + super.init(traits: .traitItalic, htmlRepresentationKey: ItalicFormatter.htmlRepresentationKey) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/LinkFormatter.swift b/Aztec/Classes/Formatters/Implementations/LinkFormatter.swift new file mode 100644 index 000000000..43b76ede8 --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/LinkFormatter.swift @@ -0,0 +1,42 @@ +import UIKit + +class LinkFormatter: StandardAttributeFormatter { + static let htmlRepresentationKey = "Link.htmlRepresentation" + + init() { + super.init(attributeKey: NSLinkAttributeName, + attributeValue: NSURL(string:"")!, + htmlRepresentationKey: LinkFormatter.htmlRepresentationKey) + } + + override func apply(to attributes: [String : Any], andStore representation: HTMLRepresentation?) -> [String: Any] { + let elementRepresentation = representation as? HTMLElementRepresentation + + guard representation == nil || elementRepresentation != nil else { + fatalError("This should not be possible. Review the logic") + } + + return apply(to: attributes, andStore: elementRepresentation) + } + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + + if let representation = representation { + let linkURL: NSURL + + if let attributeIndex = representation.attributes.index(where: { $0.name == HTMLLinkAttribute.Href.rawValue }), + let attributeValue = representation.attributes[attributeIndex].value { + + linkURL = NSURL(string: attributeValue)! + } else { + // We got a link tag without an HREF attribute + // + linkURL = NSURL(string: "")! + } + + attributeValue = linkURL + } + + return super.apply(to: attributes, andStore: representation) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/PreFormatter.swift b/Aztec/Classes/Formatters/Implementations/PreFormatter.swift new file mode 100644 index 000000000..f5b082b5f --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/PreFormatter.swift @@ -0,0 +1,57 @@ +import Foundation +import UIKit + + +// MARK: - Pre Formatter +// +open class PreFormatter: ParagraphAttributeFormatter { + + /// Font to be used + /// + let monospaceFont: UIFont + + /// Attributes to be added by default + /// + let placeholderAttributes: [String : Any]? + + + /// Designated Initializer + /// + init(monospaceFont: UIFont = UIFont(descriptor:UIFontDescriptor(name: "Courier", size: 12), size:12), placeholderAttributes: [String : Any]? = nil) { + self.monospaceFont = monospaceFont + self.placeholderAttributes = placeholderAttributes + } + + + // MARK: - Overwriten Methods + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + var resultingAttributes = attributes + let newParagraphStyle = ParagraphStyle() + + newParagraphStyle.add(property: HTMLPre(with: representation)) + + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + resultingAttributes[NSFontAttributeName] = monospaceFont + + return resultingAttributes + } + + func remove(from attributes: [String: Any]) -> [String: Any] { + guard let placeholderAttributes = placeholderAttributes else { + return attributes + } + + var resultingAttributes = attributes + for (key, value) in placeholderAttributes { + resultingAttributes[key] = value + } + + return resultingAttributes + } + + func present(in attributes: [String : Any]) -> Bool { + let font = attributes[NSFontAttributeName] as? UIFont + return font == monospaceFont + } +} diff --git a/Aztec/Classes/Formatters/Implementations/StrikethroughFormatter.swift b/Aztec/Classes/Formatters/Implementations/StrikethroughFormatter.swift new file mode 100644 index 000000000..9ea58fdaa --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/StrikethroughFormatter.swift @@ -0,0 +1,11 @@ +import UIKit + +class StrikethroughFormatter: StandardAttributeFormatter { + static let htmlRepresentationKey = "Strike.htmlRepresentation" + + init() { + super.init(attributeKey: NSStrikethroughStyleAttributeName, + attributeValue: NSUnderlineStyle.styleSingle.rawValue, + htmlRepresentationKey: StrikethroughFormatter.htmlRepresentationKey) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/TextListFormatter.swift b/Aztec/Classes/Formatters/Implementations/TextListFormatter.swift new file mode 100644 index 000000000..7bae46bd7 --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/TextListFormatter.swift @@ -0,0 +1,83 @@ +import Foundation +import UIKit + + +// MARK: - Lists Formatter +// +class TextListFormatter: ParagraphAttributeFormatter { + + /// Style of the list + /// + let listStyle: TextList.Style + + /// Attributes to be added by default + /// + let placeholderAttributes: [String : Any]? + + /// Tells if the formatter is increasing the depth of a list or simple changing the current one if any + let increaseDepth: Bool + + /// Designated Initializer + /// + init(style: TextList.Style, placeholderAttributes: [String : Any]? = nil, increaseDepth: Bool = false) { + self.listStyle = style + self.placeholderAttributes = placeholderAttributes + self.increaseDepth = increaseDepth + } + + + // MARK: - Overwriten Methods + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + let newParagraphStyle = ParagraphStyle() + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { + newParagraphStyle.setParagraphStyle(paragraphStyle) + } + + if (increaseDepth || newParagraphStyle.lists.isEmpty) { + newParagraphStyle.add(property: TextList(style: self.listStyle, with: representation)) + } else { + newParagraphStyle.replaceProperty(ofType: TextList.self, with: TextList(style: self.listStyle)) + } + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + + return resultingAttributes + } + + func remove(from attributes: [String: Any]) -> [String: Any] { + guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, + let currentList = paragraphStyle.lists.last, + currentList.style == self.listStyle + else { + return attributes + } + + let newParagraphStyle = ParagraphStyle() + newParagraphStyle.setParagraphStyle(paragraphStyle) + newParagraphStyle.removeProperty(ofType: TextList.self) + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + + return resultingAttributes + } + + func present(in attributes: [String: Any]) -> Bool { + return TextListFormatter.lists(in: attributes).last?.style == listStyle + } + + + // MARK: - Static Helpers + + static func listsOfAnyKindPresent(in attributes: [String: Any]) -> Bool { + return lists(in: attributes).isEmpty == false + } + + static func lists(in attributes: [String: Any]) -> [TextList] { + let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle + return style?.lists ?? [] + } +} + diff --git a/Aztec/Classes/Formatters/Implementations/UnderlineFormatter.swift b/Aztec/Classes/Formatters/Implementations/UnderlineFormatter.swift new file mode 100644 index 000000000..33efc6838 --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/UnderlineFormatter.swift @@ -0,0 +1,11 @@ +import UIKit + +class UnderlineFormatter: StandardAttributeFormatter { + static let htmlRepresentationKey = "Underline.htmlRepresentation" + + init() { + super.init(attributeKey: NSUnderlineStyleAttributeName, + attributeValue: NSUnderlineStyle.styleSingle.rawValue, + htmlRepresentationKey: UnderlineFormatter.htmlRepresentationKey) + } +} diff --git a/Aztec/Classes/Formatters/Implementations/VideoFormatter.swift b/Aztec/Classes/Formatters/Implementations/VideoFormatter.swift new file mode 100644 index 000000000..059e9e0a4 --- /dev/null +++ b/Aztec/Classes/Formatters/Implementations/VideoFormatter.swift @@ -0,0 +1,60 @@ +import UIKit + +class VideoFormatter: StandardAttributeFormatter { + static let htmlRepresentationKey = "Video.htmlRepresentation" + + init() { + super.init(attributeKey: NSAttachmentAttributeName, + attributeValue: VideoAttachment(identifier: NSUUID().uuidString), + htmlRepresentationKey: VideoFormatter.htmlRepresentationKey) + } + + override func apply(to attributes: [String : Any], andStore representation: HTMLRepresentation?) -> [String: Any] { + let elementRepresentation = representation as? HTMLElementRepresentation + + guard representation == nil || elementRepresentation != nil else { + fatalError("This should not be possible. Review the logic") + } + + return apply(to: attributes, andStore: elementRepresentation) + } + + func apply(to attributes: [String : Any], andStore representation: HTMLElementRepresentation?) -> [String: Any] { + + if let representation = representation { + + var namedAttributes = [String:String]() + for attributeRepresentation in representation.attributes { + if let value = attributeRepresentation.value { + namedAttributes[attributeRepresentation.name] = value + } + } + + let srcURL: URL? + + if let urlString = representation.valueForAttribute(named: "src") { + srcURL = URL(string: urlString) + namedAttributes.removeValue(forKey: "src") + } else { + srcURL = nil + } + + let posterURL: URL? + + if let urlString = representation.valueForAttribute(named: "poster") { + posterURL = URL(string: urlString) + namedAttributes.removeValue(forKey: "poster") + } else { + posterURL = nil + } + + let attachment = VideoAttachment(identifier: UUID().uuidString, srcURL: srcURL, posterURL: posterURL) + + attachment.namedAttributes = namedAttributes + + attributeValue = attachment + } + + return super.apply(to: attributes, andStore: representation) + } +} diff --git a/Aztec/Classes/Formatters/StandardAttributeFormatter.swift b/Aztec/Classes/Formatters/StandardAttributeFormatter.swift deleted file mode 100644 index ae6a404d1..000000000 --- a/Aztec/Classes/Formatters/StandardAttributeFormatter.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import UIKit - -/// Formatter to apply simple value (NSNumber, UIColor) attributes to an attributed string. -class StandardAttributeFormatter: CharacterAttributeFormatter { - - let elementType: Libxml2.StandardElementType - - let attributeKey: String - - var attributeValue: Any - - init(elementType: Libxml2.StandardElementType, attributeKey: String, attributeValue: Any) { - self.elementType = elementType - self.attributeKey = attributeKey - self.attributeValue = attributeValue - } - - func apply(to attributes: [String : Any]) -> [String: Any] { - var resultingAttributes = attributes - - resultingAttributes[attributeKey] = attributeValue - - return resultingAttributes - } - - func remove(from attributes: [String : Any]) -> [String: Any] { - var resultingAttributes = attributes - - resultingAttributes.removeValue(forKey: attributeKey) - - return resultingAttributes - } - - func present(in attributes: [String : Any]) -> Bool { - let enabled = attributes[attributeKey] != nil - return enabled - } -} - -class UnderlineFormatter: StandardAttributeFormatter { - - init() { - super.init(elementType: .u, attributeKey: NSUnderlineStyleAttributeName, attributeValue: NSUnderlineStyle.styleSingle.rawValue) - } -} - -class StrikethroughFormatter: StandardAttributeFormatter { - - init() { - super.init(elementType: .del, attributeKey: NSStrikethroughStyleAttributeName, attributeValue: NSUnderlineStyle.styleSingle.rawValue) - } -} - -class LinkFormatter: StandardAttributeFormatter { - init() { - super.init(elementType: .a, attributeKey: NSLinkAttributeName, attributeValue: NSURL(string:"")!) - } -} - -class ImageFormatter: StandardAttributeFormatter { - init() { - super.init(elementType: .img, attributeKey: NSAttachmentAttributeName, attributeValue: TextAttachment(identifier: NSUUID().uuidString)) - } -} - -class HRFormatter: StandardAttributeFormatter { - init() { - super.init(elementType: .hr, attributeKey: NSAttachmentAttributeName, attributeValue: LineAttachment()) - } -} - -class ColorFormatter: StandardAttributeFormatter { - init(color: UIColor = .black) { - super.init(elementType: .span, attributeKey: NSForegroundColorAttributeName, attributeValue: color) - } -} - diff --git a/Aztec/Classes/Formatters/TextListFormatter.swift b/Aztec/Classes/Formatters/TextListFormatter.swift deleted file mode 100644 index 364c9f1df..000000000 --- a/Aztec/Classes/Formatters/TextListFormatter.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import UIKit - -class TextListFormatter: ParagraphAttributeFormatter { - - let elementType: Libxml2.StandardElementType = .li - let listStyle: TextList.Style - let placeholderAttributes: [String : Any]? - - init(style: TextList.Style, placeholderAttributes: [String : Any]? = nil) { - self.listStyle = style - self.placeholderAttributes = placeholderAttributes - } - - func apply(to attributes: [String : Any]) -> [String: Any] { - var resultingAttributes = attributes - let newParagraphStyle = ParagraphStyle() - if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { - newParagraphStyle.setParagraphStyle(paragraphStyle) - } - if newParagraphStyle.textList == nil { - newParagraphStyle.headIndent += Metrics.defaultIndentation - newParagraphStyle.firstLineHeadIndent += Metrics.defaultIndentation - } - newParagraphStyle.textList = TextList(style: self.listStyle) - resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle - return resultingAttributes - } - - func remove(from attributes:[String: Any]) -> [String: Any] { - var resultingAttributes = attributes - let newParagraphStyle = ParagraphStyle() - guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, - paragraphStyle.textList?.style == self.listStyle - else { - return resultingAttributes - } - newParagraphStyle.setParagraphStyle(paragraphStyle) - newParagraphStyle.headIndent -= Metrics.defaultIndentation - newParagraphStyle.firstLineHeadIndent -= Metrics.defaultIndentation - newParagraphStyle.textList = nil - resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle - return resultingAttributes - } - - func present(in attributes: [String : Any]) -> Bool { - guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, - let textList = paragraphStyle.textList else { - return false - } - return textList.style == listStyle - } -} - diff --git a/Aztec/Classes/GUI/Assets.swift b/Aztec/Classes/GUI/Assets.swift new file mode 100644 index 000000000..59861699f --- /dev/null +++ b/Aztec/Classes/GUI/Assets.swift @@ -0,0 +1,17 @@ +import Foundation +import UIKit + +class Assets { + + public static var playIcon: UIImage { + let bundle = Bundle(for: self) + let playImage = UIImage(named: "play", in: bundle, compatibleWith: nil)! + return playImage + } + + public static var imageIcon: UIImage { + let bundle = Bundle(for: self) + let playImage = UIImage(named: "image", in: bundle, compatibleWith: nil)! + return playImage + } +} diff --git a/Aztec/Classes/GUI/FormatBar/FormatBar.swift b/Aztec/Classes/GUI/FormatBar/FormatBar.swift index addddc963..23b486580 100644 --- a/Aztec/Classes/GUI/FormatBar/FormatBar.swift +++ b/Aztec/Classes/GUI/FormatBar/FormatBar.swift @@ -21,41 +21,122 @@ open class FormatBar: UIView { fileprivate let scrollableStackView = UIStackView() - /// Fixed StackView + /// Top and bottom dividing lines /// - fileprivate let fixedStackView = UIStackView() + fileprivate let topDivider = UIView() + fileprivate let bottomDivider = UIView() - /// FormatBarItems to be embedded within the Scrollable StackView + /// FormatBarItems to be displayed when the bar is in its default collapsed state. + /// Each sub-array of items will be divided into a separate section in the bar. /// - open var scrollableItems = [FormatBarItem]() { - willSet { - scrollableStackView.removeArrangedSubviews(scrollableItems) + open var defaultItems = [[FormatBarItem]]() { + didSet { + let allItems = defaultItems.flatMap({ $0 }) + + configure(items: allItems) + + populateItems() } + } + + + /// Extra FormatBarItems to be displayed when the bar is in its expanded state + /// + open var overflowItems = [FormatBarItem]() { didSet { - configure(items: scrollableItems) - scrollableStackView.addArrangedSubviews(scrollableItems) + configure(items: overflowItems) + + populateItems() + + let overflowVisible = UserDefaults.standard.bool(forKey: Constants.overflowExpandedUserDefaultsKey) + setOverflowItemsVisible(overflowVisible, animated: false) + + if overflowVisible { + rotateOverflowToggleItem(.vertical, animated: false) + } + + let hasOverflowItems = !overflowItems.isEmpty + overflowToggleItem.isHidden = !hasOverflowItems } } - /// FormatBarItems to be embedded within the Fixed StackView + /// FormatBarItem used to toggle the bar's expanded state /// - open var fixedItems = [FormatBarItem]() { - willSet { - fixedStackView.removeArrangedSubviews(fixedItems) + fileprivate lazy var overflowToggleItem: FormatBarItem = { + let item = FormatBarItem(image: UIImage(), identifier: nil) + self.configureStylesFor(item) + + item.addTarget(self, action: #selector(handleToggleButtonAction), for: .touchUpInside) + item.addTarget(self, action: #selector(handleButtonTouch), for: .touchDown) + + return item + }() + + + /// The icon to show on the overflow toggle button + /// + open var overflowToggleIcon: UIImage? { + set { + overflowToggleItem.setImage(newValue, for: .normal) } - didSet { - configure(items: fixedItems) - fixedStackView.addArrangedSubviews(fixedItems) + get { + return overflowToggleItem.image(for: .normal) } } - /// Returns the collection of all of the FormatBarItem's (Scrollable + Fixed) + /// Returns the collection of all of the FormatBarItems /// private var items: [FormatBarItem] { - return scrollableItems + fixedItems + return scrollableStackView.arrangedSubviews.filter({ $0 is FormatBarItem }) as! [FormatBarItem] + } + + /// Returns the collection of all items in the stackview that are currently hidden + /// + private var hiddenItems: [FormatBarItem] { + return scrollableStackView.arrangedSubviews.filter({ $0.isHiddenInStackView && $0 is FormatBarItem }) as! [FormatBarItem] + } + + /// Returns all of the dividers (including the top divider) in the bar + /// + private var dividers: [UIView] { + return scrollableStackView.arrangedSubviews.filter({ !($0 is FormatBarItem) }) + [topDivider] + } + + /// Returns a list of all default items that don't fit within the current + /// screen width. They will be hidden, and then displayed when overflow + /// items are revealed. + /// + private var overflowedDefaultItems: ArraySlice { + // Work out how many items we can show in the bar + let availableWidth = visibleWidth + guard availableWidth > 0 else { return [] } + + let visibleItemCount = Int(floor(availableWidth / Constants.stackButtonWidth)) + + let allItems = items + guard visibleItemCount < defaultItems.flatMap({ $0 }).count else { return [] } + + return allItems.suffix(from: visibleItemCount) + } + + /// Returns the current width currently available to fit toolbar items without scrolling. + /// + private var visibleWidth: CGFloat { + return frame.width - scrollView.contentInset.left - scrollView.contentInset.right + } + + + /// Returns true if any of the overflow items in the bar are currently hidden + /// + private var overflowItemsHidden: Bool { + if let _ = overflowItems.first(where: { $0.isHiddenInStackView }) { + return true + } + + return false } @@ -63,7 +144,7 @@ open class FormatBar: UIView { /// override open var tintColor: UIColor? { didSet { - for item in items { + for item in items + [overflowToggleItem] { item.normalTintColor = tintColor } } @@ -74,7 +155,7 @@ open class FormatBar: UIView { /// open var selectedTintColor: UIColor? { didSet { - for item in items { + for item in items + [overflowToggleItem] { item.selectedTintColor = selectedTintColor } } @@ -85,7 +166,7 @@ open class FormatBar: UIView { /// open var highlightedTintColor: UIColor? { didSet { - for item in items { + for item in items + [overflowToggleItem] { item.highlightedTintColor = highlightedTintColor } } @@ -96,47 +177,56 @@ open class FormatBar: UIView { /// open var disabledTintColor: UIColor? { didSet { - for item in items { + for item in items + [overflowToggleItem] { item.disabledTintColor = disabledTintColor } } } - /// Enables or disables all of the Format Bar Items + /// Tint Color to be applied to dividers /// - open var enabled = true { + open var dividerTintColor: UIColor? { didSet { - for item in items { - item.isEnabled = enabled + for divider in (dividers + [topDivider, bottomDivider]) { + divider.backgroundColor = dividerTintColor } } } - /// Top Border's Separator Color + /// Enables or disables all of the Format Bar Items /// - open var topBorderColor = UIColor.darkGray + open var enabled = true { + didSet { + for item in items { + item.isEnabled = enabled + } + overflowToggleItem.isEnabled = enabled + } + } - /// Bounds Change Observer - /// - override open var bounds: CGRect { + open override var bounds: CGRect { + didSet { + updateVisibleItemsForCurrentBounds() + } + } + open override var frame: CGRect { didSet { - // Note: Under certain conditions, frame.didSet might get called instead of bounds.didSet. - // We're observing both for that reason! - refreshScrollingLock() + updateVisibleItemsForCurrentBounds() } } + fileprivate func updateVisibleItemsForCurrentBounds() { + guard overflowItemsHidden else { return } - /// Bounds Change Observer - /// - override open var frame: CGRect { - didSet { - // Note: Under certain conditions, frame.didSet might get called instead of bounds.didSet. - // We're observing both for that reason! - refreshScrollingLock() + // Ensure that any items that wouldn't fit are hidden + let allItems = items + let overflowedItems = overflowedDefaultItems + overflowItems + + for item in allItems { + item.isHiddenInStackView = overflowedItems.contains(item) } } @@ -146,17 +236,21 @@ open class FormatBar: UIView { public init() { super.init(frame: .zero) - - // Make sure we getre-drawn whenever the bounds change! - layer.needsDisplayOnBoundsChange = true + backgroundColor = .white configure(scrollView: scrollView) - configure(stackView: scrollableStackView) - configure(stackView: fixedStackView) + configureScrollableStackView() - scrollView.addSubview(scrollableStackView) addSubview(scrollView) - addSubview(fixedStackView) + scrollView.addSubview(scrollableStackView) + + topDivider.translatesAutoresizingMaskIntoConstraints = false + bottomDivider.translatesAutoresizingMaskIntoConstraints = false + addSubview(topDivider) + addSubview(bottomDivider) + + overflowToggleItem.translatesAutoresizingMaskIntoConstraints = false + addSubview(overflowToggleItem) configureConstraints() } @@ -168,48 +262,10 @@ open class FormatBar: UIView { } - - // MARK: - Drawing! - - open override func draw(_ rect: CGRect) { - super.draw(rect) - - guard let context = UIGraphicsGetCurrentContext() else { - return - } - - // Setup the Context - let lineWidthInPoints = Constants.topBorderHeightInPixels / UIScreen.main.scale - - context.clear(rect) - context.setLineWidth(lineWidthInPoints) - - // Background - let bgColor = backgroundColor ?? .white - bgColor.setFill() - context.fill(rect) - - // Top Separator - topBorderColor.setStroke() - - context.setShouldAntialias(false) - context.move(to: CGPoint(x: 0, y: lineWidthInPoints)) - context.addLine(to: CGPoint(x: bounds.maxX, y: lineWidthInPoints)) - context.strokePath() - - // Scrollable / Fixed `>` Separator - let originX = fixedStackView.frame.minX - Constants.fixedStackViewInsets.left - - context.setShouldAntialias(true) - context.move(to: CGPoint(x: originX, y: bounds.minY)) - context.addLine(to: CGPoint(x: originX + Constants.fixedSeparatorMidPointPaddingX, y: bounds.midY)) - context.addLine(to: CGPoint(x: originX, y: bounds.maxY)) - context.strokePath() - } - override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - refreshStackViewsSpacing() + + updateVisibleItemsForCurrentBounds() } @@ -218,8 +274,22 @@ open class FormatBar: UIView { /// Selects all of the FormatBarItems matching a collection of Identifiers /// open func selectItemsMatchingIdentifiers(_ identifiers: [FormattingIdentifier]) { + let identifiers = Set(identifiers) + for item in items { - if let identifier = item.identifier { + if let alternativeIcons = item.alternativeIcons, alternativeIcons.count > 0 { + // If the item has a matching alternative identifier, use that first and set selected + if let alternativeIdentifier = alternativeIcons.keys.first(where: { identifiers.contains($0) }) { + item.useAlternativeIconForIdentifier(alternativeIdentifier) + item.isSelected = true + } else { + // If the item has alternative identifiers, but none of them match, + // reset the icon and deselect it + item.resetIcon() + item.isSelected = false + } + } else if let identifier = item.identifier { + // Otherwise, select it if the identifier matches item.isSelected = identifiers.contains(identifier) } } @@ -228,27 +298,86 @@ open class FormatBar: UIView { // MARK: - Actions + @IBAction func handleButtonTouch(_ sender: FormatBarItem) { + formatter?.formatBarTouchesBegan(self) + } + @IBAction func handleButtonAction(_ sender: FormatBarItem) { - formatter?.handleActionForIdentifier(sender.identifier!) + guard let identifier = sender.identifier else { return } + + formatter?.handleActionForIdentifier(identifier, barItem: sender) } -} + @IBAction func handleToggleButtonAction(_ sender: FormatBarItem) { + let shouldExpand = overflowItemsHidden + + overflowToolbar(expand: shouldExpand) + } + + open func overflowToolbar(expand shouldExpand: Bool) { + setOverflowItemsVisible(shouldExpand) + + let direction: OverflowToggleAnimationDirection = shouldExpand ? .vertical : .horizontal + rotateOverflowToggleItem(direction, animated: true) + + UserDefaults.standard.set(shouldExpand, forKey: Constants.overflowExpandedUserDefaultsKey) + + formatter?.formatBar(self, didChangeOverflowState: (shouldExpand) ? .visible : .hidden) + } + + private func setOverflowItemsVisible(_ visible: Bool, animated: Bool = true) { + guard overflowItemsHidden == visible else { return } + + // Animate backwards if we're disappearing + let items = visible ? hiddenItems : (overflowedDefaultItems + overflowItems).reversed() + + // Currently only doing the pop animation for appearance + if animated && visible { + for (index, item) in items.enumerated() { + animate(item: item, visible: visible, withDelay: Double(index) * Animations.itemPop.interItemAnimationDelay) + } + } else { + scrollView.contentOffset = .zero + items.forEach({ $0.isHiddenInStackView = !visible }) + } + } +} // MARK: - Configuration Helpers // private extension FormatBar { - /// Detaches a given collection of FormatBarItem's + /// Populates the bar with the combined default and overflow items. + /// Overflow items will be hidden by default. /// - func detach(items: [FormatBarItem]) { - for item in items { - item.removeFromSuperview() + func populateItems() { + scrollableStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for items in defaultItems { + scrollableStackView.addArrangedSubviews(items) + + if let last = defaultItems.last, + items != last { + addDivider() + } } + + scrollableStackView.addArrangedSubviews(overflowItems) + + updateVisibleItemsForCurrentBounds() + } + + /// Inserts a divider into the bar. + /// + func addDivider() { + let divider = FormatBarDividerItem() + divider.backgroundColor = dividerTintColor + scrollableStackView.addArrangedSubview(divider) } - /// Sets up a given collection of FormatBarItem's1 + /// Sets up a given collection of FormatBarItem's /// func configure(items: [FormatBarItem]) { for item in items { @@ -260,23 +389,28 @@ private extension FormatBar { /// Sets up a given FormatBarItem /// func configure(item: FormatBarItem) { + configureStylesFor(item) + + item.addTarget(self, action: #selector(handleButtonAction), for: .touchUpInside) + item.addTarget(self, action: #selector(handleButtonTouch), for: .touchDown) + } + + func configureStylesFor(_ item: FormatBarItem) { item.tintColor = tintColor item.selectedTintColor = selectedTintColor item.highlightedTintColor = highlightedTintColor item.disabledTintColor = disabledTintColor - - item.addTarget(self, action: #selector(handleButtonAction), for: .touchUpInside) } - /// Sets up a given StackView + /// Sets up the scrollable StackView /// - func configure(stackView: UIStackView) { - stackView.axis = .horizontal - stackView.spacing = Constants.stackViewCompactSpacing - stackView.alignment = .center - stackView.distribution = .equalCentering - stackView.translatesAutoresizingMaskIntoConstraints = false + func configureScrollableStackView() { + scrollableStackView.axis = .horizontal + scrollableStackView.spacing = Constants.stackViewCompactSpacing + scrollableStackView.alignment = .center + scrollableStackView.distribution = .equalSpacing + scrollableStackView.translatesAutoresizingMaskIntoConstraints = false } @@ -284,93 +418,154 @@ private extension FormatBar { /// func configure(scrollView: UIScrollView) { scrollView.isScrollEnabled = true - scrollView.alwaysBounceHorizontal = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = false scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.delegate = self + + // Add padding at the end to account for overflow button + let layoutDirection = UIView.userInterfaceLayoutDirection(for: semanticContentAttribute) + switch layoutDirection { + case .leftToRight: + scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: Constants.stackButtonWidth) + case .rightToLeft: + scrollView.contentInset = UIEdgeInsets(top: 0, left: Constants.stackButtonWidth, bottom: 0, right: 0) + } } /// Sets up the Constraints /// func configureConstraints() { - let fixedInsets = Constants.fixedStackViewInsets - let scrollableInsets = Constants.scrollableStackViewInsets + let overflowTrailingConstraint = overflowToggleItem.trailingAnchor.constraint(equalTo: trailingAnchor) + overflowTrailingConstraint.priority = UILayoutPriorityDefaultLow NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), - scrollView.topAnchor.constraint(equalTo: topAnchor), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) + overflowToggleItem.topAnchor.constraint(equalTo: topAnchor), + overflowToggleItem.bottomAnchor.constraint(equalTo: bottomAnchor), + overflowToggleItem.leadingAnchor.constraint(greaterThanOrEqualTo: scrollableStackView.trailingAnchor), + overflowTrailingConstraint + ]) NSLayoutConstraint.activate([ - fixedStackView.leadingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: fixedInsets.left), - fixedStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -1 * fixedInsets.right), - fixedStackView.topAnchor.constraint(equalTo: topAnchor), - fixedStackView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) + topDivider.leadingAnchor.constraint(equalTo: leadingAnchor), + topDivider.trailingAnchor.constraint(equalTo: trailingAnchor), + topDivider.topAnchor.constraint(equalTo: topAnchor), + topDivider.heightAnchor.constraint(equalToConstant: Constants.horizontalDividerHeight) + ]) NSLayoutConstraint.activate([ - scrollableStackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: scrollableInsets.left), - scrollableStackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -1 * scrollableInsets.right), + bottomDivider.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomDivider.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomDivider.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomDivider.heightAnchor.constraint(equalToConstant: Constants.horizontalDividerHeight) + ]) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + NSLayoutConstraint.activate([ + scrollableStackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + scrollableStackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), scrollableStackView.topAnchor.constraint(equalTo: scrollView.topAnchor), scrollableStackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), scrollableStackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor), - ]) - } - - - /// Refreshes the Stack View(s) Spacing, according to the Horizontal Size Class - /// - func refreshStackViewsSpacing() { - let horizontallyCompact = traitCollection.horizontalSizeClass == .compact - let stackViewSpacing = horizontallyCompact ? Constants.stackViewCompactSpacing : Constants.stackViewRegularSpacing - - scrollableStackView.spacing = stackViewSpacing - fixedStackView.spacing = stackViewSpacing - } - - - /// Disables scrolling whenever there's no actual overflow - /// - func refreshScrollingLock() { - layoutIfNeeded() - scrollView.isScrollEnabled = scrollView.contentSize.width > scrollView.frame.width + ]) } } - // MARK: - Animation Helpers // -extension FormatBar { +private extension FormatBar { private var scrollableContentSize: CGSize { return scrollView.contentSize } - private var scrollabeVisibleSize: CGSize { + private var scrollableVisibleSize: CGSize { return scrollView.frame.size } - open func animateSlightPeekWhenOverflows() { - guard scrollableContentSize.width > scrollabeVisibleSize.width else { - return + func animate(item: FormatBarItem, visible: Bool, withDelay delay: TimeInterval) { + let hide = { + item.transform = Animations.itemPop.initialTransform + item.alpha = 0 + } + + let unhide = { + item.transform = CGAffineTransform.identity + item.alpha = 1.0 + } + + let pop = { + UIView.animate(withDuration: Animations.itemPop.duration, + delay: delay, + usingSpringWithDamping: Animations.itemPop.springDamping, + initialSpringVelocity: Animations.itemPop.springInitialVelocity, + options: [], + animations: (visible) ? unhide : hide, + completion: nil) + } + + if visible { + hide() + UIView.animate(withDuration: Animations.durationShort, + animations: { item.isHiddenInStackView = false }, + completion: { _ in + pop() + }) + } else { + unhide() + pop() + } + } + + enum OverflowToggleAnimationDirection { + case horizontal + case vertical + + var transform: CGAffineTransform { + switch self { + case .horizontal: + return .identity + case .vertical: + return CGAffineTransform(rotationAngle: (.pi / 2)) + } } + } - let originalRect = CGRect(origin: .zero, size: scrollabeVisibleSize) - let peekOrigin = CGPoint(x: scrollableContentSize.width * Animations.peekWidthRatio, y: 0) - let peekRect = CGRect(origin: peekOrigin, size: scrollabeVisibleSize) + func rotateOverflowToggleItem(_ direction: OverflowToggleAnimationDirection, animated: Bool, completion: ((Bool) -> Void)? = nil) { + let transform = { + self.overflowToggleItem.transform = direction.transform + } - UIView.animate(withDuration: Animations.durationLong, delay: Animations.delayZero, options: .curveEaseInOut, animations: { - self.scrollView.scrollRectToVisible(peekRect, animated: false) - }, completion: { _ in - UIView.animate(withDuration: Animations.durationShort, delay: Animations.delayZero, options: .curveEaseInOut, animations: { - self.scrollView.scrollRectToVisible(originalRect, animated: false) - }, completion: nil) - }) + if (animated) { + UIView.animate(withDuration: Animations.toggleItem.duration, + delay: 0, + usingSpringWithDamping: Animations.toggleItem.springDamping, + initialSpringVelocity: Animations.toggleItem.springInitialVelocity, + options: [], + animations: transform, + completion: completion) + } else { + transform() + completion?(true) + } } } +// MARK: - UIScrollViewDelegate +extension FormatBar: UIScrollViewDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + formatter?.formatBarTouchesBegan(self) + } +} // MARK: - Private Constants // @@ -381,14 +576,46 @@ private extension FormatBar { static let durationShort = TimeInterval(0.15) static let delayZero = TimeInterval(0) static let peekWidthRatio = CGFloat(0.05) + + struct toggleItem { + static let duration = TimeInterval(0.6) + static let springDamping = CGFloat(0.5) + static let springInitialVelocity = CGFloat(0.1) + } + + struct itemPop { + static let interItemAnimationDelay = TimeInterval(0.1) + static let initialTransform = CGAffineTransform(scaleX: 0.01, y: 0.01) + static let duration = TimeInterval(0.65) + static let springDamping = CGFloat(0.4) + static let springInitialVelocity = CGFloat(1.0) + } } struct Constants { + static let overflowExpandedUserDefaultsKey = "AztecFormatBarOverflowExpandedKey" static let fixedSeparatorMidPointPaddingX = CGFloat(5) - static let fixedStackViewInsets = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 10) - static let scrollableStackViewInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) - static let stackViewCompactSpacing = CGFloat(7) - static let stackViewRegularSpacing = CGFloat(20) - static let topBorderHeightInPixels = CGFloat(1) + static let stackViewCompactSpacing = CGFloat(0) + static let stackViewRegularSpacing = CGFloat(0) + static let stackButtonWidth = CGFloat(44) + static let horizontalDividerHeight = CGFloat(1) + } +} + +private extension UIView { + /// Required to work around a bug in UIStackView where items don't become + /// hidden / unhidden correctly if you set their `isHidden` property + /// to the same value twice in a row. See http://www.openradar.me/22819594 + /// + var isHiddenInStackView: Bool { + set { + if isHidden != newValue { + isHidden = newValue + } + } + + get { + return isHidden + } } } diff --git a/Aztec/Classes/GUI/FormatBar/FormatBarDelegate.swift b/Aztec/Classes/GUI/FormatBar/FormatBarDelegate.swift index 8a6ecf3c7..0f362c68f 100644 --- a/Aztec/Classes/GUI/FormatBar/FormatBarDelegate.swift +++ b/Aztec/Classes/GUI/FormatBar/FormatBarDelegate.swift @@ -1,8 +1,23 @@ import Foundation import UIKit +public enum FormatBarOverflowState { + case hidden + case visible +} public protocol FormatBarDelegate : NSObjectProtocol { + /// Prompts the delegate that the bar item with the specified identifier was tapped, + /// and it should take appropriate action. + /// + func handleActionForIdentifier(_ identifier: FormattingIdentifier, barItem: FormatBarItem) + + /// Informs the delegate that a touch down event was received on the format bar. + /// + func formatBarTouchesBegan(_ formatBar: FormatBar) - func handleActionForIdentifier(_ identifier: FormattingIdentifier) + /// Called when the overflow items in the format bar are either shown or hidden + /// as a result of the user tapping the toggle button. + /// + func formatBar(_ formatBar: FormatBar, didChangeOverflowState overflowState: FormatBarOverflowState) } diff --git a/Aztec/Classes/GUI/FormatBar/FormatBarItem.swift b/Aztec/Classes/GUI/FormatBar/FormatBarItem.swift index 29d4e25a2..83873bc08 100644 --- a/Aztec/Classes/GUI/FormatBar/FormatBarItem.swift +++ b/Aztec/Classes/GUI/FormatBar/FormatBarItem.swift @@ -74,21 +74,48 @@ open class FormatBarItem: UIButton { } + // MARK: - Icons + + /// A list of alternative icons that can be switched out for + /// this item's default icon if their formatting identifiers are detected + /// + public var alternativeIcons: [FormattingIdentifier: UIImage]? = nil + + /// Switch out this item's icon for the icon that matches the specified identifier + /// + public func useAlternativeIconForIdentifier(_ identifier: FormattingIdentifier) { + if let icon = alternativeIcons?[identifier] { + setImage(icon, for: .normal) + } + } + + /// Reset this item's icon back to default + /// + public func resetIcon() { + setImage(originalIcon, for: .normal) + } + + private var originalIcon: UIImage // MARK: - Lifecycle - public convenience init(image: UIImage, identifier: FormattingIdentifier) { + public convenience init(image: UIImage, identifier: FormattingIdentifier? = nil) { let defaultFrame = CGRect(x: 0, y: 0, width: 44, height: 44) self.init(image: image, frame: defaultFrame) self.identifier = identifier } + open override var intrinsicContentSize: CGSize { + return CGSize(width: 44.0, height: 44.0) + } public init(image: UIImage, frame: CGRect) { + self.originalIcon = image + super.init(frame: frame) self.setImage(image, for: UIControlState()) - self.adjustsImageWhenDisabled = true - self.adjustsImageWhenHighlighted = true + self.adjustsImageWhenDisabled = false + self.adjustsImageWhenHighlighted = false } @@ -118,3 +145,9 @@ open class FormatBarItem: UIButton { tintColor = normalTintColor } } + +class FormatBarDividerItem: UIView { + override var intrinsicContentSize: CGSize { + return CGSize(width: 1.0, height: 44.0) + } +} diff --git a/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift b/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift index 160c29ea2..cecc842c6 100644 --- a/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift +++ b/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift @@ -13,7 +13,6 @@ public enum FormattingIdentifier: String { case media = "media" case more = "more" case sourcecode = "sourcecode" - case header = "header" case header1 = "header1" case header2 = "header2" case header3 = "header3" @@ -21,4 +20,5 @@ public enum FormattingIdentifier: String { case header5 = "header5" case header6 = "header6" case horizontalruler = "horizontalruler" + case p = "p" } diff --git a/Aztec/Classes/Libxml2/Converters/In/InHTMLConverter.swift b/Aztec/Classes/Libxml2/Converters/In/InHTMLConverter.swift index a720dc173..d1ee71c4c 100644 --- a/Aztec/Classes/Libxml2/Converters/In/InHTMLConverter.swift +++ b/Aztec/Classes/Libxml2/Converters/In/InHTMLConverter.swift @@ -2,20 +2,13 @@ import Foundation import libxml2 extension Libxml2.In { - class HTMLConverter: Converter { + class HTMLConverter: SafeConverter { - typealias EditContext = Libxml2.EditContext typealias RootNode = Libxml2.RootNode enum Error: String, Swift.Error { case NoRootNode = "No root node" } - - let editContext: EditContext? - - required init(editContext: EditContext? = nil) { - self.editContext = editContext - } /// Converts HTML data into an HTML Node representing the same data. /// @@ -24,7 +17,7 @@ extension Libxml2.In { /// /// - Returns: the HTML root node. /// - func convert(_ html: String) throws -> RootNode { + func convert(_ html: String) -> RootNode { // We wrap the HTML into a special root node, since it helps avoid conversion issues // with libxml2, where the library would add custom tags to "fix" the HTML code we @@ -74,28 +67,14 @@ extension Libxml2.In { } let rootNodePtr = xmlDocGetRootElement(document) + let nodeConverter = NodeConverter() - if let rootNode = rootNodePtr?.pointee { - - // TODO: If the root node has siblings, they're loaded as children instead (by - // libxml2). We need to test this a bit more, because saving the HTML back will - // produce a different result unless there's some way to identify this scenario. - // - // Example HTML: - // - // It may be a good idea to wrap the HTML in a single fake root node before parsing - // it to bypass this behaviour. - // - let nodeConverter = NodeConverter(editContext: editContext) - - guard let node = nodeConverter.convert(rootNode) as? RootNode else { - throw Error.NoRootNode - } - - return node - } else { - throw Error.NoRootNode + guard let rootNode = rootNodePtr?.pointee, + let node = nodeConverter.convert(rootNode) as? RootNode else { + return RootNode(children: []) } + + return node } } } diff --git a/Aztec/Classes/Libxml2/Converters/In/InNodeConverter.swift b/Aztec/Classes/Libxml2/Converters/In/InNodeConverter.swift index 49e4ee3a4..50a0fabf1 100644 --- a/Aztec/Classes/Libxml2/Converters/In/InNodeConverter.swift +++ b/Aztec/Classes/Libxml2/Converters/In/InNodeConverter.swift @@ -6,18 +6,11 @@ extension Libxml2.In { typealias Attribute = Libxml2.Attribute typealias CommentNode = Libxml2.CommentNode - typealias EditContext = Libxml2.EditContext typealias ElementNode = Libxml2.ElementNode typealias Node = Libxml2.Node typealias RootNode = Libxml2.RootNode typealias TextNode = Libxml2.TextNode - let editContext: EditContext? - - required init(editContext: EditContext? = nil) { - self.editContext = editContext - } - /// Converts a single node (from libxml2) into an HTML.Node. /// /// - Parameters: @@ -66,12 +59,12 @@ extension Libxml2.In { var children = [Node]() if rawNode.children != nil { - let nodesConverter = NodesConverter(editContext: editContext) + let nodesConverter = NodesConverter() children.append(contentsOf: nodesConverter.convert(rawNode.children)) } let attributes = createAttributes(fromNode: rawNode) - let node = ElementNode(name: nodeName, attributes: attributes, children: children, editContext: editContext) + let node = ElementNode(name: nodeName, attributes: attributes, children: children) // TODO: This can be optimized to be set during instantiation of the child nodes. // @@ -93,11 +86,11 @@ extension Libxml2.In { var children = [Node]() if rawNode.children != nil { - let nodesConverter = NodesConverter(editContext: editContext) + let nodesConverter = NodesConverter() children.append(contentsOf: nodesConverter.convert(rawNode.children)) } - let node = RootNode(children: children, editContext: editContext) + let node = RootNode(children: children) // TODO: This can be optimized to be set during instantiation of the child nodes. // @@ -108,6 +101,30 @@ extension Libxml2.In { return node } + func hasNode(_ rawNode:xmlNode, ancestorOfType type: Libxml2.StandardElementType) -> Bool { + var parentNode = rawNode.parent + while parentNode != nil { + guard let xmlNode = parentNode?.pointee else { + return true + } + if xmlNode.name != nil && String(cString:xmlNode.name) == type.rawValue { + return false + } + parentNode = xmlNode.parent + } + return true + } + + /// This method check that in the current context it makes sense to clean up newlines and double spaces from text. + /// For example if you are inside a pre element you shoulnd't clean up the nodes. + /// + /// - Parameter rawNode: the base node to check + /// + /// - Returns: true if sanitization should happen, false otherwise + func shouldSanitizeText(inNode rawNode: xmlNode) -> Bool { + return hasNode(rawNode, ancestorOfType: .pre) + } + /// Creates an HTML.TextNode from a libxml2 element node. /// /// - Parameters: @@ -116,8 +133,11 @@ extension Libxml2.In { /// - Returns: the HTML.TextNode /// fileprivate func createTextNode(_ rawNode: xmlNode) -> TextNode { - let text = String(cString: rawNode.content) - let node = TextNode(text: sanitize(text), editContext: editContext) + var text = String(cString: rawNode.content) + if shouldSanitizeText(inNode: rawNode) { + text = sanitize(text) + } + let node = TextNode(text: text) return node } @@ -131,7 +151,7 @@ extension Libxml2.In { /// fileprivate func createCommentNode(_ rawNode: xmlNode) -> CommentNode { let text = String(cString: rawNode.content) - let node = CommentNode(text: text, editContext: editContext) + let node = CommentNode(text: text) return node } @@ -147,7 +167,14 @@ extension Libxml2.In { let hasAnEndingSpace = text.hasSuffix(String(.space)) let hasAStartingSpace = text.hasPrefix(String(.space)) - let trimmedText = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + // We cannot use CharacterSet.whitespacesAndNewlines directly, because it includes + // U+000A, which is non-breaking space. We need to maintain it. + // + let whitespace = CharacterSet.whitespacesAndNewlines + let whitespaceToKeep = CharacterSet(charactersIn: String(.nonBreakingSpace)) + let whitespaceToRemove = whitespace.subtracting(whitespaceToKeep) + + let trimmedText = text.trimmingCharacters(in: whitespaceToRemove) var singleSpaceText = trimmedText let doubleSpace = " " let singleSpace = " " @@ -156,7 +183,7 @@ extension Libxml2.In { singleSpaceText = singleSpaceText.replacingOccurrences(of: doubleSpace, with: singleSpace) } - let noBreaksText = singleSpaceText.replacingOccurrences(of: String(.newline), with: "") + let noBreaksText = singleSpaceText.replacingOccurrences(of: String(.lineFeed), with: "") let endingSpace = !noBreaksText.isEmpty && hasAnEndingSpace ? String(.space) : "" let startingSpace = !noBreaksText.isEmpty && hasAStartingSpace ? String(.space) : "" return "\(startingSpace)\(noBreaksText)\(endingSpace)" diff --git a/Aztec/Classes/Libxml2/Converters/In/InNodesConverter.swift b/Aztec/Classes/Libxml2/Converters/In/InNodesConverter.swift index c395a6311..1a025d43b 100644 --- a/Aztec/Classes/Libxml2/Converters/In/InNodesConverter.swift +++ b/Aztec/Classes/Libxml2/Converters/In/InNodesConverter.swift @@ -7,10 +7,8 @@ extension Libxml2.In { /// class NodesConverter: SafeCLinkedListToArrayConverter { - typealias EditContext = Libxml2.EditContext - - required init(editContext: EditContext? = nil) { - super.init(elementConverter: NodeConverter(editContext: editContext), next: { return $0.next }) + required init() { + super.init(elementConverter: NodeConverter(), next: { return $0.next }) } } } diff --git a/Aztec/Classes/Libxml2/Converters/Out/OutHTMLAttributeConverter.swift b/Aztec/Classes/Libxml2/Converters/Out/OutHTMLAttributeConverter.swift deleted file mode 100644 index 5f34bf48b..000000000 --- a/Aztec/Classes/Libxml2/Converters/Out/OutHTMLAttributeConverter.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -import libxml2 - -extension Libxml2.Out { - class AttributeConverter: Converter { - - typealias Attribute = Libxml2.Attribute - typealias StringAttribute = Libxml2.StringAttribute - - fileprivate let node: xmlNodePtr? - - init(forNode node: xmlNodePtr? = nil) { - self.node = node - } - - /// Converts a single HTML.Attribute into a single libxml2 attribute - /// - /// - Parameters: - /// - attribute: the HTML.Attribute to convert. - /// - /// - Returns: an libxml2 attribute. - /// - func convert(_ rawAttribute: Attribute) -> xmlAttrPtr { - var attribute: xmlAttrPtr - - if let stringAttribute = rawAttribute as? StringAttribute { - attribute = createStringAttribute(stringAttribute) - } else { - attribute = createAttribute(rawAttribute) - } - - return attribute; - } - - /// Creates a libxml2 string attribute from a HTML.StringAttribute. - /// - /// - Parameters: - /// - rawAttribute: HTML.StringAttribute. - /// - /// - Returns: libxml2 string attribute - /// - fileprivate func createStringAttribute(_ rawStringAttribute: StringAttribute) -> xmlAttrPtr { - let name = rawStringAttribute.name - let nameCStr = name.cString(using: String.Encoding.utf8)! - let namePtr = UnsafePointer(OpaquePointer(nameCStr)) - - let value = rawStringAttribute.value - let valueCStr = value.cString(using: String.Encoding.utf8)! - let valuePtr = UnsafePointer(OpaquePointer(valueCStr)) - - return xmlNewProp(node, namePtr, valuePtr) - } - - /// Creates a libxml2 attribute from a HTML.Attribute. - /// - /// - Parameters: - /// - rawAttribute: HTML.Attribute. - /// - /// - Returns: libxml2 attribute - /// - fileprivate func createAttribute(_ rawAttribute: Attribute) -> xmlAttrPtr { - let name = rawAttribute.name - let nameCStr = name.cString(using: String.Encoding.utf8)! - let namePtr = UnsafePointer(OpaquePointer(nameCStr)) - - return xmlNewProp(node, namePtr, nil) - } - } -} diff --git a/Aztec/Classes/Libxml2/Converters/Out/OutHTMLConverter.swift b/Aztec/Classes/Libxml2/Converters/Out/OutHTMLConverter.swift index 5dd247df1..0b8a16739 100644 --- a/Aztec/Classes/Libxml2/Converters/Out/OutHTMLConverter.swift +++ b/Aztec/Classes/Libxml2/Converters/Out/OutHTMLConverter.swift @@ -1,47 +1,264 @@ import Foundation import libxml2 + +// MARK: - HTML Prettifier! +// extension Libxml2.Out { class HTMLConverter: Converter { - - typealias EditContext = Libxml2.EditContext - typealias Node = Libxml2.Node - typealias ElementNode = Libxml2.ElementNode - typealias RootNode = Libxml2.RootNode - - required init() { - } - /// Converts the a Libxml2 Node into HTML representing the same data. + // MARK: - Typealiases + + typealias Attribute = Libxml2.Attribute + typealias StringAttribute = Libxml2.StringAttribute + typealias ElementNode = Libxml2.ElementNode + typealias Node = Libxml2.Node + typealias TextNode = Libxml2.TextNode + typealias CommentNode = Libxml2.CommentNode + typealias RootNode = Libxml2.RootNode + + /// Indentation Spaces to be applied + /// + let indentationSpaces: Int + + /// Indicates whether we want Pretty Print or not + /// + let prettyPrint: Bool + + + /// Default Initializer /// /// - Parameters: - /// - rawNode: the Libxml2 Node to convert. + /// - prettyPrint: Indicates whether if the output should be pretty-formatted, or not. + /// - indentationSpaces: Indicates the number of indentation spaces to be applied, per level. /// - /// - Returns: a String object representing the specified HTML data. + init(prettyPrint: Bool = false, indentationSpaces: Int = 2) { + self.indentationSpaces = indentationSpaces + self.prettyPrint = prettyPrint + } + + + /// Converts a Node into it's HTML String Representation /// func convert(_ rawNode: Node) -> String { + return convert(node: rawNode).trimmingCharacters(in: CharacterSet.newlines) + } + } +} + + +// MARK: - Nodes: Serialization +// +private extension Libxml2.Out.HTMLConverter { + + /// Serializes a Node into it's HTML String Representation + /// + func convert(node: Node, level: Int = 0) -> String { + switch node { + case let node as RootNode: + return convert(root: node) + case let node as CommentNode: + return convert(comment: node) + case let node as ElementNode: + return convert(element: node, level: level) + case let node as TextNode: + return convert(text: node) + default: + fatalError("We're missing support for a node type. This should not happen.") + } + } + + + /// Serializes a RootNode into it's HTML String Representation + /// + private func convert(root node: RootNode) -> String { + return node.children.reduce("") { (result, node) in + return result + convert(node: node) + } + } + + + /// Serializes a CommentNode into it's HTML String Representation + /// + private func convert(comment node: CommentNode) -> String { + return "" + } + + + /// Serializes an ElementNode into it's HTML String Representation + /// + private func convert(element node: ElementNode, level: Int) -> String { + let opening = openingTag(for: node, at: level) + + guard let closing = closingTag(for: node, at: level) else { + return opening + } + + let children = node.children.reduce("") { (html, child)in + return html + convert(node: child, level: level + 1) + } + + return opening + children + closing + } + + + /// Serializes a TextNode into it's HTML String Representation + /// + private func convert(text node: TextNode) -> String { + return node.text().encodeHtmlEntities() + } +} + + + +// MARK: - ElementNode: Helpers +// +private extension Libxml2.Out.HTMLConverter { + + /// Returns the Opening Tag for a given Element Node + /// + func openingTag(for node: ElementNode, at level: Int) -> String { + let prefix = requiresOpeningTagPrefix(node) ? prefixForTag(at: level) : "" + let attributes = convert(attributes: node.attributes) + + return prefix + "<" + node.name + attributes + ">" + } + + + /// Returns the Closing Tag for a given Element Node, if its even required + /// + func closingTag(for node: ElementNode, at level: Int) -> String? { + guard requiresClosingTag(node) else { + return nil + } + + let prefix = requiresClosingTagPrefix(node) ? prefixForTag(at: level) : "" + let posfix = requiresClosingTagPosfix(node) ? posfixForTag() : "" + + return prefix + "" + posfix + } + + + /// Returns the Tag Prefix String at the specified level + /// + private func prefixForTag(at level: Int) -> String { + let indentation = level > 0 ? String(repeating: String(.space), count: level * indentationSpaces) : "" + return String(.lineFeed) + indentation + } + + + /// Returns the Tag Posfix String + /// + private func posfixForTag() -> String { + return String(.lineFeed) + } + + + /// OpeningTag Prefix: Required whenever the node is a blocklevel element + /// + private func requiresOpeningTagPrefix(_ node: ElementNode) -> Bool { + return node.isBlockLevelElement() && prettyPrint + } + + + /// ClosingTag Prefix: Required whenever one of the children is a blocklevel element + /// + private func requiresClosingTagPrefix(_ node: ElementNode) -> Bool { + return node.children.contains { child in + let elementChild = child as? ElementNode + return elementChild?.isBlockLevelElement() == true && prettyPrint + } + } + - guard let outputBuffer = xmlAllocOutputBuffer(nil) else { - fatalError("This should not ever happen. Prevent the code from going further to avoid possible data loss.") - } + /// ClosingTag Posfix: Required whenever the node is blocklevel, and the right sibling is not + /// + private func requiresClosingTagPosfix(_ node: ElementNode) -> Bool { + guard let rightSibling = node.rightSibling() else { + return false + } + + let rightElementNode = rightSibling as? ElementNode + let isRightNodeRegularElement = rightElementNode == nil || rightElementNode?.isBlockLevelElement() == false - let xmlDocPtr = xmlNewDoc(nil) + return isRightNodeRegularElement && node.isBlockLevelElement() && prettyPrint + } - defer { - xmlFreeDoc(xmlDocPtr) - xmlOutputBufferClose(outputBuffer) - } - let xmlNodePtr = Libxml2.Out.NodeConverter().convert(rawNode) + /// Indicates if an ElementNode is a Void Element (expected not to have a closing tag), or not. + /// + private func requiresClosingTag(_ node: ElementNode) -> Bool { + return Constants.voidElements.contains(node.name) == false + } +} - xmlDocSetRootElement(xmlDocPtr, xmlNodePtr) - htmlNodeDumpFormatOutput(outputBuffer, xmlDocPtr, xmlNodePtr, nil, 0) - let htmlDumpString = String(cString: xmlBufContent(outputBuffer.pointee.buffer)) - let finalString = htmlDumpString.replacingOccurrences(of: "<\(RootNode.name)>", with: "").replacingOccurrences(of: "", with: "") +// MARK: - Attributes: Serialization +// +private extension Libxml2.Out.HTMLConverter { - return finalString + /// Serializes a collection of Attributes into their HTML Form + /// + func convert(attributes: [Attribute]) -> String { + return attributes.reduce("") { (html, attribute) in + return html + String(.space) + convert(attribute: attribute) } } + + + /// Serializes an Attribute into it's corresponding String Value, depending on the actual Attribute subclass. + /// + private func convert(attribute: Attribute) -> String { + switch attribute { + case let stringAttribute as StringAttribute where !isBooleanAttribute(name: attribute.name): + return convert(stringAttribute: stringAttribute) + default: + return convert(rawAttribute: attribute) + } + } + + + /// Serializes a given StringAttribute. + /// + private func convert(stringAttribute attribute: StringAttribute) -> String { + return attribute.name + "=\"" + attribute.value + "\"" + } + + + /// Serializes a given Attribute + /// + private func convert(rawAttribute: Attribute) -> String { + return rawAttribute.name + } + + + /// Indicates whether if an Attribute is expected to have a value, or not. + /// + private func isBooleanAttribute(name: String) -> Bool { + return Constants.booleanAttributes.contains(name) + } +} + + + +// MARK: - Private Constants +// +private extension Libxml2.Out.HTMLConverter { + + struct Constants { + + /// List of 'Void Elements', that are expected *not* to have a closing tag. + /// + /// Ref. http://w3c.github.io/html/syntax.html#void-elements + /// + static let voidElements = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", + "meta", "param", "source", "track", "wbr"] + + /// List of Boolean Attributes, that are not expected to have an actual value + /// + static let booleanAttributes = ["checked", "compact", "declare", "defer", "disabled", "ismap", + "multiple", "nohref", "noresize", "noshade", "nowrap", "readonly", + "selected"] + } } diff --git a/Aztec/Classes/Libxml2/Converters/Out/OutHTMLNodeConverter.swift b/Aztec/Classes/Libxml2/Converters/Out/OutHTMLNodeConverter.swift deleted file mode 100644 index c7bab78f5..000000000 --- a/Aztec/Classes/Libxml2/Converters/Out/OutHTMLNodeConverter.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import libxml2 - -extension Libxml2.Out { - class NodeConverter: Converter { - - typealias Attribute = Libxml2.Attribute - typealias ElementNode = Libxml2.ElementNode - typealias Node = Libxml2.Node - typealias TextNode = Libxml2.TextNode - typealias CommentNode = Libxml2.CommentNode - - /// Converts a single HTML.Node into a libxml2 node - /// - /// - Parameters: - /// - attributes: the HTML.Node convert. - /// - /// - Returns: a libxml2 node. - /// - func convert(_ rawNode: Node) -> xmlNodePtr { - var node: xmlNodePtr - - if let textNode = rawNode as? TextNode { - node = createTextNode(textNode) - } else if let elementNode = rawNode as? ElementNode { - node = createElementNode(elementNode) - } else if let commentNode = rawNode as? CommentNode { - node = createCommentNode(commentNode) - } else { - fatalError("We're missing support for a node type. This should not happen.") - } - - return node - } - - /// Creates a libxml2 element node from a HTML.Node - /// - /// - Parameters: - /// - rawNode: HTML.ElementNode - /// - /// - Returns: the the libxml2 xmlNode. - /// - fileprivate func createElementNode(_ rawNode: ElementNode) -> xmlNodePtr { - let nodeConverter = NodeConverter() - - let name = rawNode.name - let nameCStr = name.cString(using: String.Encoding.utf8)! - let namePtr = UnsafePointer(OpaquePointer(nameCStr)) - - let node = xmlNewNode(nil, namePtr)! - let attributeConverter = AttributeConverter(forNode: node) - - for rawAttribute in rawNode.attributes { - let _ = attributeConverter.convert(rawAttribute) - } - - for child in rawNode.children { - let childNode = nodeConverter.convert(child) - xmlAddChild(node, childNode) - } - - return node - } - - /// Creates a libxml2 element node from a HTML.TextNode. - /// - /// - Parameters: - /// - rawNode: the HTML.TextNode. - /// - /// - Returns: the libxml2 xmlNode - /// - fileprivate func createTextNode(_ rawNode: TextNode) -> xmlNodePtr { - let value = rawNode.text() - let valueCStr = value.cString(using: String.Encoding.utf8)! - let valuePtr = UnsafePointer(OpaquePointer(valueCStr)) - - return xmlNewText(valuePtr) - } - - /// Creates a libxml2 element node from a HTML.CommentNode. - /// - /// - Parameters: - /// - rawNode: the HTML.CommentNode. - /// - /// - Returns: the libxml2 xmlNode - /// - fileprivate func createCommentNode(_ rawNode: CommentNode) -> xmlNodePtr { - let value = rawNode.comment - let valueCStr = value.cString(using: String.Encoding.utf8)! - let valuePtr = UnsafePointer(OpaquePointer(valueCStr)) - - return xmlNewComment(valuePtr) - } - } -} diff --git a/Aztec/Classes/Libxml2/DOM/Data/Attribute.swift b/Aztec/Classes/Libxml2/DOM/Data/Attribute.swift index 3c14f86ce..fc51e26f5 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/Attribute.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/Attribute.swift @@ -3,7 +3,7 @@ extension Libxml2 { /// Represents a basic attribute with no value. This is also the base class for all other /// attributes. /// - class Attribute: CustomReflectable { + class Attribute: CustomReflectable, Equatable, Hashable { let name: String // MARK: - CustomReflectable @@ -19,8 +19,22 @@ extension Libxml2 { init(name: String) { self.name = name } + + + // MARK - Hashable + + var hashValue: Int { + return name.hashValue + } + + // MARK: - Equatable + + static func ==(lhs: Attribute, rhs: Attribute) -> Bool { + return type(of: lhs) == type(of: rhs) && lhs.name == rhs.name + } } + /// Represents an attribute with an generic string value. This is useful for storing attributes /// that do have a value, which we don't know how to parse. This is only meant as a mechanism /// to maintain the attribute's information. @@ -41,5 +55,18 @@ extension Libxml2 { super.init(name: name) } + + // MARK - Hashable + + override var hashValue: Int { + return name.hashValue ^ value.hashValue + } + + + // MARK: - Equatable + + static func ==(lhs: StringAttribute, rhs: StringAttribute) -> Bool { + return type(of: lhs) == type(of: rhs) && lhs.name == rhs.name && lhs.value == rhs.value + } } } diff --git a/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift b/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift index 4c476fb8a..dc2bef2b8 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift @@ -3,7 +3,7 @@ import Foundation extension Libxml2 { /// Comment nodes use to hold HTML comments like this: /// - class CommentNode: Node, LeafNode { + class CommentNode: Node { var comment: String @@ -17,31 +17,10 @@ extension Libxml2 { // MARK: - Initializers - init(text: String, editContext: EditContext? = nil) { + init(text: String) { comment = text - super.init(name: "comment", editContext: editContext) - } - - /// Node length. - /// - override func length() -> Int { - let nsString = text() as NSString - return nsString.length - } - - // MARK: - LeafNode - - override func text() -> String { - return String(.newline) - } - - override func deleteCharacters(inRange range: NSRange) { - guard range.location == 0 && range.length == length() else { - return - } - - removeFromParent() + super.init(name: "comment") } } } diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index 373f0eec3..3eba809d6 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -7,8 +7,28 @@ extension Libxml2 { /// class ElementNode: Node { - fileprivate(set) var attributes = [Attribute]() - fileprivate(set) var children: [Node] + var attributes = [Attribute]() + var children: [Node] { + didSet { + for child in children where child.parent !== self { + child.parent?.remove(child) + child.parent = self + } + } + } + + private static let headerLevels: [StandardElementType] = [.h1, .h2, .h3, .h4, .h5, .h6] + + class func elementTypeForHeaderLevel(_ headerLevel: Int) -> StandardElementType? { + if headerLevel < 1 || headerLevel > headerLevels.count { + return nil + } + return headerLevels[headerLevel - 1] + } + + private static let knownElements: [StandardElementType] = [.a, .b, .br, .blockquote, .del, .div, .em, .h1, .h2, .h3, .h4, .h5, .h6, .hr, .i, .img, .li, .ol, .p, .pre, .s, .span, .strike, .strong, .u, .ul, .video] + private static let mergeableBlocklevelElements: [StandardElementType] = [.p, .h1, .h2, .h3, .h4, .h5, .h6, .hr, .ol, .ul, .li, .blockquote] + private static let mergeableStyleElements: [StandardElementType] = [.i, .em, .b, .strong, .strike, .u] internal var standardName: StandardElementType? { get { @@ -24,32 +44,35 @@ extension Libxml2 { } } - // MARK: - Editing behavior configuration + // MARK - Hashable + + override public var hashValue: Int { + let attributesHash = attributes.reduce(0) { (result, attribute) in + return result ^ attribute.hashValue + } + + return name.hashValue ^ attributesHash + } - static let elementsThatInterruptStyleAtEdges: [StandardElementType] = [.a, .br, .img, .hr] // MARK: - Initializers - init(name: String, attributes: [Attribute], children: [Node], editContext: EditContext? = nil) { + init(name: String, attributes: [Attribute], children: [Node]) { self.attributes.append(contentsOf: attributes) self.children = children - super.init(name: name, editContext: editContext) - - for child in children { - - if let parent = child.parent { - parent.remove(child) - } - - child.parent = self - } + super.init(name: name) } - convenience init(descriptor: ElementNodeDescriptor, children: [Node] = [], editContext: EditContext? = nil) { - self.init(name: descriptor.name, attributes: descriptor.attributes, children: children, editContext: editContext) + convenience init(descriptor: ElementNodeDescriptor, children: [Node] = []) { + self.init(name: descriptor.name, attributes: descriptor.attributes, children: children) } - + + convenience init(type: StandardElementType, attributes: [Attribute] = [], children: [Node] = []) { + self.init(name: type.rawValue, attributes: attributes, children: children) + } + + // MARK: - Node Constructors static func `break`() -> ElementNode { @@ -57,12 +80,20 @@ extension Libxml2 { } // MARK: - Node Overrides - - /// Node length. Calculated by adding the length of all child nodes. + + /// Checks if the specified node requires a closing paragraph separator. /// - override func length() -> Int { - let nsString = text() as NSString - return nsString.length + func needsClosingParagraphSeparator() -> Bool { + + guard children.count == 0 else { + return false + } + + guard !hasRightBlockLevelSibling() else { + return true + } + + return !isLastInTree() && isLastInAncestorEndingInBlockLevelSeparation() } // MARK: - Node Queries @@ -165,1471 +196,192 @@ extension Libxml2 { return standardName.isBlockLevelNodeName() } - func isNodeType(_ type:StandardElementType) -> Bool { + func isNodeType(_ type: StandardElementType) -> Bool { return type.equivalentNames.contains(name.lowercased()) } - - // MARK: - DOM Queries - - /// Returns the index of the specified child node. This method should only be called when - /// there's 100% certainty that this node should contain the specified child node, as it - /// fails otherwise. - /// - /// Call `children.indexOf()` if you need to test the parent-child relationship instead. - /// - /// - Parameters: - /// - childNode: the child node to find the index of. - /// - /// - Returns: the index of the specified child node. - /// - func indexOf(childNode: Node) -> Int { - guard let index = children.index(of: childNode) else { - fatalError("Broken parent-child relationship found.") - } - - return index - } - /// Returns the index of the child node intersecting the specified location. - /// - /// - Parameters: - /// - location: the text location that the child node must intersect. - /// - /// - Returns: The index of the child node intersecting the specified text location. If the text location is - /// exactly between two nodes, the left hand node will always be returned. The only exception to this - /// rule is for text location zero, which will always result in index zero being returned. - /// - func indexOf(childNodeIntersecting location: Int) -> (index: Int, intersection: Int) { - - guard children.count > 0 else { - fatalError("An element node without children should never happen.") - } - - guard location != 0 else { - return (0, 0) - } - - var adjustedLocation = location - - for (index, child) in children.enumerated() { - - if (adjustedLocation <= child.length()) { - return (index, adjustedLocation) - } - - adjustedLocation = adjustedLocation - child.length() - } - - fatalError("The specified location is out of bounds.") - } - /// Get a list of child nodes intersecting the specified range. + /// Retrieves the last child matching a specific filtering closure. /// /// - Parameters: - /// - targetRange: the range we're intersecting the child nodes with. The range is in - /// this node's coordinates (the parent node's coordinates, from the children - /// PoV). - /// - preferLeftNode: for zero-length target ranges, this parameter is used to - /// disambiguate if we're referring to the last position in a node, or to the - /// first position in the following node (since both positions have the same - /// offset). By default this is true. + /// - filter: the filtering closure. /// - /// - Returns: an array of pairs of child nodes and their ranges in child coordinates. + /// - Returns: the requested node, or `nil` if there are no nodes matching the request. /// - func childNodes(intersectingRange targetRange: NSRange) -> [(child: Node, intersection: NSRange)] { - var results = [(child: Node, intersection: NSRange)]() - - enumerateChildNodes(intersectingRange: targetRange) { (child, intersection) in - results.append((child, intersection)) - } - - return results + func lastChild(matching filter: (Node) -> Bool) -> Node? { + return children.filter(filter).last } - /// Enumerate the child nodes intersecting the specified range. + + /// Indicates whether the children of the specified node can be merged in, or not. /// /// - Parameters: - /// - targetRange: the range we're intersecting the child nodes with. The range is in - /// this node's coordinates (the parent node's coordinates, from the children - /// PoV). - /// - preferLeftNode: for zero-length target ranges, this parameter is used to - /// disambiguate if we're referring to the last position in a node, or to the - /// first position in the following node (since both positions have the same - /// offset). - /// - matchFound: the closure to execute for each child node intersecting - /// `targetRange`. + /// - node: Target node for which we'll determine Merge-ability status. /// - /// - Returns: an array of child nodes and their intersection. The intersection range is in - /// child coordinates. + /// - Returns: true if both nodes can be merged, or not. /// - func enumerateChildNodes(intersectingRange targetRange: NSRange, onMatchFound matchFound: (_ child: Node, _ intersection: NSRange) -> Void ) { - - var offset = Int(0) - - for child in children { - - let childLength = child.length() - let childRange = NSRange(location: offset, length: childLength) - let intersectionRange: NSRange - let childRangeInterceptsTargetRange: Bool - - if targetRange.length > 0 { - - intersectionRange = NSIntersectionRange(childRange, targetRange) - - childRangeInterceptsTargetRange = - (intersectionRange.location > 0 && intersectionRange.length < childLength) - || intersectionRange.length > 0 - } else { - let targetLocation = targetRange.location - - childRangeInterceptsTargetRange = - targetLocation == offset - || targetLocation == offset + childLength - || (targetLocation > offset && targetLocation < offset + childLength) - - intersectionRange = NSRange(location: targetLocation, length: 0) - } - - if childRangeInterceptsTargetRange { - - let intersectionRangeInChildCoordinates = NSRange(location: intersectionRange.location - offset, length: intersectionRange.length) - - matchFound(child, intersectionRangeInChildCoordinates) - } - - offset += childLength + func canMergeChildren(of node: ElementNode, blocklevelEnforced: Bool) -> Bool { + guard name == node.name && Set(attributes) == Set(node.attributes) else { + return false } - } - /// Returns the lowest block-level child elements intersecting the specified range. - /// - /// - Parameters: - /// - targetRange: the range we're intersecting the child nodes with. The range is in - /// this node's coordinates (the parent node's coordinates, from the children - /// PoV). - /// - /// - Returns: An array of child nodes and their intersection. The intersection range is in - /// child coordinates. - /// Whenever a range doesn't intersect a block-level node, `self` (the receiver) is returned - /// as the owner of that range. - /// - func lowestBlockLevelElements(intersectingRange targetRange: NSRange) -> [(element: ElementNode, intersection: NSRange)] { - var results = [(element: ElementNode, intersection: NSRange)]() + guard let standardName = self.standardName else { + return false + } - enumerateLowestBlockLevelElements(intersectingRange: targetRange) { result in - results.append(result) + guard blocklevelEnforced else { + return ElementNode.mergeableStyleElements.contains(standardName) } - return results + return ElementNode.mergeableBlocklevelElements.contains(standardName) } - /// Enumerate the lowest block-level child elements intersecting the specified range. - /// - /// - Parameters: - /// - targetRange: the range we're intersecting the child nodes with. The range is in - /// this node's coordinates (the parent node's coordinates, from the children - /// PoV). - /// - matchFound: the closure to execute for each child element intersecting - /// `targetRange`. - /// - /// - Returns: an array of child nodes and their intersection. The intersection range is in - /// child coordinates. - /// - func enumerateLowestBlockLevelElements(intersectingRange targetRange: NSRange, onMatchFound matchFound: @escaping (_ element: ElementNode, _ intersection: NSRange) -> Void ) { - - enumerateLowestBlockLevelElements( - intersectingRange: targetRange, - onMatchNotFound: { (range) in - matchFound(self, range) - }, - onMatchFound: matchFound) - } - /// Enumerate the child block-level elements intersecting the specified range. - /// - /// - Parameters: - /// - targetRange: the range we're intersecting the child nodes with. The range is in - /// this node's coordinates (the parent node's coordinates, from the children - /// PoV). - /// - matchNotFound: the closure to execute for any subrange of `targetRange` that - /// doesn't have a block-level node intersecting it. - /// - matchFound: the closure to execute for each child element intersecting - /// `targetRange`. - /// - /// - Returns: an array of child nodes and their intersection. The intersection range is in - /// child coordinates. - /// - fileprivate func enumerateLowestBlockLevelElements(intersectingRange targetRange: NSRange, onMatchNotFound matchNotFound: @escaping (_ range: NSRange) -> Void, onMatchFound matchFound: @escaping (_ element: ElementNode, _ intersection: NSRange) -> Void ) { - - enumerateLowestElements( - intersectingRange: targetRange, - bailIf: { (element) -> Bool in - return !element.isBlockLevelElement() - }, - onMatchNotFound: matchNotFound, - onMatchFound: matchFound) - } + // MARK: - DOM Queries - /// Enumerate the child elements intersecting the specified range and fulfilling a specified - /// condition. + typealias NodeMatchTest = (_ node: Node) -> Bool + typealias NodeIntersectionReport = (_ node: Node, _ intersection: NSRange) -> Void + typealias RangeReport = (_ range: NSRange) -> Void + + /// Retrieves the right-side sibling of the child at the specified index. /// /// - Parameters: - /// - targetRange: the range we're intersecting the child nodes with. The range is in - /// this node's coordinates (the parent node's coordinates, from the children - /// PoV). - /// - bailCondition: a condition that makes the search bail from a specific tree search - /// branch. - /// - matchNotFound: the closure to execute for any subrange of `targetRange` that - /// doesn't have a block-level node intersecting it. - /// - matchFound: the closure to execute for each child element intersecting - /// `targetRange`. + /// - index: the index of the child to get the sibling of. /// - /// - Returns: an array of child nodes and their intersection. The intersection range is in - /// child coordinates. + /// - Returns: the requested sibling, or `nil` if there's none. /// - fileprivate func enumerateLowestElements(intersectingRange targetRange: NSRange, bailIf bailCondition: @escaping (_ element: ElementNode) -> Bool, onMatchNotFound matchNotFound: @escaping (_ range: NSRange) -> Void, onMatchFound matchFound: @escaping (_ element: ElementNode, _ intersection: NSRange) -> Void ) { + func sibling(rightOf childIndex: Int) -> T? { - var rangeWithoutMatch: NSRange? - var offset = Int(0) - - for child in children { + guard childIndex >= 0 && childIndex < children.count else { + fatalError("Out of bounds!") + } + + guard childIndex < children.count - 1 else { + return nil + } - let childLength = child.length() - let childRange = NSRange(location: offset, length: childLength) - - if let intersection = targetRange.intersect(withRange: childRange) { - - let intersectionInChildCoordinates = NSRange(location: intersection.location - offset, length: intersection.length) - - if let childElement = child as? ElementNode, !bailCondition(childElement) { - - childElement.enumerateLowestElements( - intersectingRange: intersectionInChildCoordinates, - bailIf: bailCondition, - onMatchNotFound: { (range) in - - if let previousRangeWithoutMatch = rangeWithoutMatch { - rangeWithoutMatch = NSRange(location: previousRangeWithoutMatch.location, length: previousRangeWithoutMatch.length + range.length) - } else { - rangeWithoutMatch = NSRange(location: offset + range.location, length: range.length) - } - }, - onMatchFound: { [weak self] (child, intersection) in - - guard let strongSelf = self else { - return - } - - if let previousRangeWithoutMatch = rangeWithoutMatch { - if !bailCondition(strongSelf) { - matchFound(strongSelf, previousRangeWithoutMatch) - rangeWithoutMatch = nil - } else { - matchNotFound(previousRangeWithoutMatch) - } - } - - matchFound(child, intersection) - }) - } else { - if let previousRangeWithoutMatch = rangeWithoutMatch { - rangeWithoutMatch = NSRange(location: previousRangeWithoutMatch.location, length: previousRangeWithoutMatch.length + intersectionInChildCoordinates.length) - } else { - rangeWithoutMatch = intersection - - } - } - } + let siblingNode = children[childIndex + 1] - offset += childLength + // Ignore empty text nodes. + // + if let textSibling = siblingNode as? TextNode, textSibling.length() == 0 { + return sibling(rightOf: childIndex + 1) } - if let previousRangeWithoutMatch = rangeWithoutMatch { - if !bailCondition(self) { - matchFound(self, previousRangeWithoutMatch) - rangeWithoutMatch = nil - } else { - matchNotFound(previousRangeWithoutMatch) - } - } + return siblingNode as? T } - typealias NodeMatchTest = (_ node: Node) -> Bool - typealias NodeIntersectionReport = (_ node: Node, _ intersection: NSRange) -> Void - typealias RangeReport = (_ range: NSRange) -> Void + // MARK: - DOM modification - /// Enumerates the descendants that match the specified condition, and intersection range - /// between those descendants and the specified range constraint. - /// - /// - Important: the receiver is also tested. + /// Replaces the specified node with several new nodes. /// /// - Parameters: - /// - targetRange: the range we're intersecting the child nodes with. The range is in - /// the receiver's coordinate system. - /// - isMatch: a closure that evaluates nodes for matches. - /// - matchFound: the closure to execute for each child element intersecting - /// `targetRange`. - /// - matchNotFound: the closure to execute for any subrange of `targetRange` that - /// doesn't have a block-level node intersecting it. + /// - child: the node to remove. + /// - newChildren: the new child nodes to insert. /// - func enumerateFirstDescendants( - in targetRange: NSRange, - matching isMatch: NodeMatchTest, - onMatchFound matchFound: NodeIntersectionReport?, - onMatchNotFound matchNotFound: RangeReport?) { - - assert(range().contains(range: targetRange)) - assert(matchFound != nil || matchNotFound != nil) - - guard !isMatch(self) else { - matchFound?(self, targetRange) - return - } - - var rangeWithoutMatch: NSRange? - var offset = Int(0) - - let ensureProcessingOfRangeWithoutMatch = { () in - if let previousRangeWithoutMatch = rangeWithoutMatch { - matchNotFound?(previousRangeWithoutMatch) - rangeWithoutMatch = nil - } - } - - let processMatchFound = { (child: Node, intersection: NSRange) -> () in - ensureProcessingOfRangeWithoutMatch() - matchFound?(child, intersection) - } - - let extendRangeWithoutMatch = { (range: NSRange) in - if let previousRangeWithoutMatch = rangeWithoutMatch { - rangeWithoutMatch = NSRange(location: previousRangeWithoutMatch.location, length: previousRangeWithoutMatch.length + range.length) - } else { - rangeWithoutMatch = range - } + func replace(child: Node, with newChildren: [Node]) { + guard let childIndex = children.index(of: child) else { + fatalError("This case should not be possible. Review the logic triggering this.") } - for child in children { - - let childLength = child.length() - let childRange = NSRange(location: offset, length: childLength) - - if let intersection = targetRange.intersect(withRange: childRange) { - if isMatch(child) { - processMatchFound(child, intersection) - } else if let childElement = child as? ElementNode { - - let intersectionInChildCoordinates = NSRange(location: intersection.location - offset, length: intersection.length) - - childElement.enumerateFirstDescendants( - in: intersectionInChildCoordinates, - matching: isMatch, - onMatchFound: { (child, intersection) in - processMatchFound(child, intersection) - }, - onMatchNotFound: { (range) in - let adjustedRange = NSRange(location: range.location + offset, length: range.length) - extendRangeWithoutMatch(adjustedRange) - }) - } else { - extendRangeWithoutMatch(intersection) - } - } - - offset += childLength + for newNode in newChildren { + newNode.parent = self } - ensureProcessingOfRangeWithoutMatch() + children.remove(at: childIndex) + children.insert(contentsOf: newChildren, at: childIndex) } - /// Returns the lowest-level element node in this node's hierarchy that wraps the specified - /// range. If no child element node wraps the specified range, this method returns this - /// node. - /// - /// - Parameters: - /// - range: the range we want to find the wrapping node of. - /// - /// - Returns: the lowest-level element node wrapping the specified range, or this node if - /// no child node fulfills the condition. + /// Removes the receiver from its parent. /// - func lowestElementNodeWrapping(_ range: NSRange) -> ElementNode { - - var offset = 0 - - for child in children { - let length = child.length() - let nodeRange = NSRange(location: offset, length: length) - let nodeWrapsRange = (NSUnionRange(nodeRange, range).length == nodeRange.length) - - if nodeWrapsRange { - if let elementNode = child as? ElementNode { - - let childRange = NSRange(location: range.location - offset, length: range.length) - - return elementNode.lowestElementNodeWrapping(childRange) - } else { - return self - } - } - - offset = offset + length + func removeFromParent(undoManager: UndoManager? = nil) { + guard let parent = parent else { + assertionFailure("It doesn't make sense to call this method without a parent") + return } - return self + parent.remove(self) } - /// Returns the lowest-level text node in this node's hierarchy that wraps the specified - /// range. If no child text node wraps the specified range, this method returns nil. + + /// Removes the specified child node. Only updates its parent if specified. /// /// - Parameters: - /// - range: the range we want to find the wrapping node of. - /// - /// - Returns: the lowest-level text node wrapping the specified range, or nil if - /// no child node fulfills the condition. + /// - child: the child node to remove. + /// - updateParent: whether the children node's parent must be update to `nil` or not. + /// If not specified, the parent is updated. /// - func lowestTextNodeWrapping(_ range: NSRange) -> TextNode? { - - var offset = 0 - - for child in children { - let length = child.length() - let nodeRange = NSRange(location: offset, length: length) - let nodeWrapsRange = (NSUnionRange(nodeRange, range).length == nodeRange.length) - - if nodeWrapsRange { - if let textNode = child as? TextNode { - return textNode - } else if let elementNode = child as? ElementNode { + func remove(_ child: Node, updateParent: Bool = true) { - let childRange = NSRange(location: range.location - offset, length: range.length) + guard let index = children.index(of: child) else { + assertionFailure("Can't remove a node that's not a child.") + return + } - return elementNode.lowestTextNodeWrapping(childRange) - } else { - return nil - } - } + children.remove(at: index) - offset = offset + length + if updateParent { + child.parent = nil } - - return nil } - /// Calls this method to obtain all the leaf nodes containing a specified range. + /// Removes the specified child nodes. Only updates their parents if specified. /// /// - Parameters: - /// - range: the range that the text nodes must cover. - /// - /// - Returns: an array of leaf nodes and a range specifying how much of the node contents - /// makes part of the input range. The returned range's location is an offset - /// from the node's location. - /// The array of leaf nodes is ordered by order of appearance (0 being the first). + /// - children: the child nodes to remove. + /// - updateParent: whether the children node's parent must be update to `nil` or not. + /// If not specified, the parent is updated. /// - func leafNodesWrapping(_ range: NSRange) -> [(node: LeafNode, range: NSRange)] { - - var results = [(node: LeafNode, range: NSRange)]() - var offset = 0 - + func remove(_ children: [Node], updateParent: Bool = true) { for child in children { + remove(child, updateParent: updateParent) + } + } - let childRange = NSRange(location: offset, length: child.length()) - let childInterceptsRange = - (range.length == 0 && NSLocationInRange(range.location, childRange)) - || NSIntersectionRange(range, childRange).length != 0 + // MARK: - Editing behavior - if childInterceptsRange { - if let textNode = child as? TextNode { + func isSupportedByEditor() -> Bool { - var intersection = NSIntersectionRange(range, childRange) - intersection.location = intersection.location - offset + guard let standardName = standardName else { + return false + } - results.append((node: textNode, range: intersection)) - } else if let commentNode = child as? CommentNode { - var intersection = NSIntersectionRange(range, childRange) - intersection.location = intersection.location - offset - - results.append((node: commentNode, range: intersection)) - } else if let elementNode = child as? ElementNode { - let offsetRange = NSRange(location: range.location - offset, length: range.length) + return ElementNode.knownElements.contains(standardName) + } + } - results.append(contentsOf: elementNode.leafNodesWrapping(offsetRange)) - } else { - assertionFailure("This case should not be possible. Review the logic triggering this.") - } - let fullRangeCovered = range.location + range.length <= childRange.location + childRange.length + class RootNode: ElementNode { - if fullRangeCovered { - break - } - } + static let name = "aztec.htmltag.rootnode" - offset = offset + child.length() + override var parent: Libxml2.ElementNode? { + get { + return nil } - return results - } - - /// Retrieves the left-side sibling of the child at the specified index. - /// - /// - Parameters: - /// - index: the index of the child to get the sibling of. - /// - /// - Returns: the requested sibling, or `nil` if there's none. - /// - - func sibling(leftOf childIndex: Int) -> T? { - - guard childIndex >= 0 && childIndex < children.count else { - fatalError("Out of bounds!") - } - - guard childIndex > 0, - let sibling = children[childIndex - 1] as? T else { - return nil + set { } - - return sibling } + + // MARK: - CustomReflectable - /// Retrieves the right-side sibling of the child at the specified index. - /// - /// - Parameters: - /// - index: the index of the child to get the sibling of. - /// - /// - Returns: the requested sibling, or `nil` if there's none. - /// - - func sibling(rightOf childIndex: Int) -> T? { - - guard childIndex >= 0 && childIndex < children.count else { - fatalError("Out of bounds!") - } - - guard childIndex < children.count - 1, - let sibling = children[childIndex + 1] as? T else { - return nil + override public var customMirror: Mirror { + get { + return Mirror(self, children: ["name": name, "children": children]) } - - return sibling } - /// Finds any left-side descendant with any of the specified names. - /// - /// - Parameters: - /// - evaluate: the closure to evaluate the candidate. `true` means we have a good - /// candidate. - /// - bail: the closure that will be used to evaluate if the descendant search must - /// bail. - /// - /// - Returns: the matching element, if any was found, or `nil`. - /// - - fileprivate func find(leftSideDescendantEvaluatedBy evaluate: ((T) -> Bool), bailIf bail: ((T) -> Bool) = { _ in return false }) -> T? { - - guard children.count > 0 else { - return nil - } - - let child = children[0] - - if let match = child as? T, !bail(match) && evaluate(match) { - return match - } else if let element = child as? ElementNode { - return element.find(leftSideDescendantEvaluatedBy: evaluate, bailIf: bail) - } else { - return nil - } - } - - /// Finds any right-side descendant with any of the specified names. - /// - /// - Parameters: - /// - evaluate: the closure to evaluate the candidate. `true` means we have a good - /// candidate. - /// - bail: the closure that will be used to evaluate if the descendant search must - /// bail. - /// - /// - Returns: the matching element, if any was found, or `nil`. - /// - - fileprivate func find(rightSideDescendantEvaluatedBy evaluate: ((T) -> Bool), bailIf bail: ((T) -> Bool) = { _ in return false }) -> T? { - - guard children.count > 0 else { - return nil - } - - let child = children[children.count - 1] - - if let match = child as? T, !bail(match) && evaluate(match) { - return match - } else if let element = child as? ElementNode { - return element.find(rightSideDescendantEvaluatedBy: evaluate, bailIf: bail) - } else { - return nil - } - } - - override func text() -> String { - - if let nodeType = standardName, - let implicitRepresentation = nodeType.implicitRepresentation() { - - return implicitRepresentation.string - } - - var text = "" - for child in children { - text = text + child.text() - } - return text - } - - - /// Returns the plain visible text for a specified range. - /// - /// - Parameters: - /// - range: the range of the text inside this node that we want to retrieve. - /// - func text(forRange range: NSRange) -> String { - let textNodesAndRanges = leafNodesWrapping(range) - var text = "" - - for textNodeAndRange in textNodesAndRanges { - let nodeText = textNodeAndRange.node.text() - let range = nodeText.rangeFromNSRange(textNodeAndRange.range)! - - text = text + nodeText.substring(with: range) - } - - return text - } - - // MARK: - DOM modification - - /// Appends a node to the list of children for this element. - /// - /// - Parameters: - /// - child: the node to append. - /// - func append(_ child: Node) { - child.removeFromParent() - children.append(child) - child.parent = self - } - - /// Appends a node to the list of children for this element. - /// - /// - Parameters: - /// - child: the node to append. - /// - func append(_ children: [Node]) { - for child in children { - append(child) - } - } - - /// Prepends a node to the list of children for this element. - /// - /// - Parameters: - /// - child: the node to prepend. - /// - func prepend(_ child: Node) { - insert(child, at: 0) - } - - /// Prepends children to the list of children for this element. - /// - /// - Parameters: - /// - children: the nodes to prepend. - /// - func prepend(_ children: [Node]) { - for index in stride(from: (children.count - 1), through: 0, by: -1) { - prepend(children[index]) - } - } - - /// Inserts a node into the list of children for this element. - /// - /// - Parameters: - /// - child: the node to insert. - /// - index: the position where to insert the node. - /// - func insert(_ child: Node, at index: Int) { - child.removeFromParent() - children.insert(child, at: index) - child.parent = self - } - - /// Replaces the specified node with several new nodes. - /// - /// - Parameters: - /// - child: the node to remove. - /// - newChildren: the new child nodes to insert. - /// - func replace(child: Node, with newChildren: [Node]) { - guard let childIndex = children.index(of: child) else { - fatalError("This case should not be possible. Review the logic triggering this.") - } - - for newNode in newChildren { - newNode.parent = self - } - - children.remove(at: childIndex) - children.insert(contentsOf: newChildren, at: childIndex) - } - - /// Removes the receiver from its parent. - /// - func removeFromParent(undoManager: UndoManager? = nil) { - guard let parent = parent else { - assertionFailure("It doesn't make sense to call this method without a parent") - return - } - - parent.remove(self) - } - - - /// Removes the specified child node. Only updates its parent if specified. - /// - /// - Parameters: - /// - child: the child node to remove. - /// - updateParent: whether the children node's parent must be update to `nil` or not. - /// If not specified, the parent is updated. - /// - func remove(_ child: Node, updateParent: Bool = true) { - - guard let index = children.index(of: child) else { - assertionFailure("Can't remove a node that's not a child.") - return - } - - registerUndoForRemove(child) - children.remove(at: index) - - if updateParent { - child.parent = nil - } - } - - /// Removes the specified child nodes. Only updates their parents if specified. - /// - /// - Parameters: - /// - children: the child nodes to remove. - /// - updateParent: whether the children node's parent must be update to `nil` or not. - /// If not specified, the parent is updated. - /// - func remove(_ children: [Node], updateParent: Bool = true) { - for child in children { - remove(child, updateParent: updateParent) - } - } - - /// Retrieves all child nodes positioned after a specified location. - /// - /// - Parameters: - /// - splitLocation: marks the split location. - /// - /// - Returns: the requested nodes. - /// - fileprivate func splitChildren(after splitLocation: Int) -> [Node] { - - var result = [Node]() - var childStartLocation = Int(0) - - for child in children { - let childLength = child.length() - let childEndLocation = childStartLocation + childLength - - if childStartLocation >= splitLocation { - result.append(child) - } else if childStartLocation < splitLocation && childEndLocation > splitLocation { - - let splitLocationInChild = splitLocation - childStartLocation - let splitRange = NSRange(location: splitLocationInChild, length: childEndLocation - splitLocation) - - child.split(forRange: splitRange) - result.append(child) - } - - childStartLocation = childEndLocation - } - - return result - } - - /// Retrieves all child nodes positioned before a specified location. - /// - /// - Parameters: - /// - splitLocation: marks the split location. - /// - /// - Returns: the requested nodes. - /// - fileprivate func splitChildren(before splitLocation: Int) -> [Node] { - - var result = [Node]() - var childOffset = Int(0) - - for child in children { - let childLength = child.length() - let childEndLocation = childOffset + childLength - - if childEndLocation <= splitLocation { - result.append(child) - } else if childOffset < splitLocation && childEndLocation > splitLocation { - - let splitLocationInChild = splitLocation - childOffset - let splitRange = NSRange(location: 0, length: splitLocationInChild) - - child.split(forRange: splitRange) - result.append(child) - } - - childOffset = childOffset + childLength - } - - return result - } - - /// Pushes the receiver up in the DOM structure, by wrapping an exact copy of the parent - /// node, inserting all the receivers children to it, and adding the receiver to its - /// grandparent node. - /// - /// The result is that the order of the receiver and its parent node will be inverted. - /// - func pushUp(left: Bool) { - guard let parent = parent, let grandParent = parent.parent else { - // This is actually an error scenario, as this method should not be called on - // nodes that don't have a parent and a grandparent. - // - // The reason why this would be an error is that we're either trying to push-up - // a node without a parent, or we're trying to push up a node to become the root - // node. - // - // The reason why we allow - // - fatalError("Do not call this method if the node doesn't have a parent and grandparent node.") - } - - guard let parentIndex = grandParent.children.index(of: parent) else { - fatalError("The grandparent element should contain the parent element.") - } - - let originalParent = parent - - let parentDescriptor = ElementNodeDescriptor(name: parent.name, attributes: parent.attributes) - wrap(children: children, inElement: parentDescriptor) - - let indexOffset = left ? 0 : 1 - - grandParent.insert(self, at: parentIndex + indexOffset) - - if originalParent.children.count == 0 { - originalParent.removeFromParent() - } - } - - /// Evaluates the left sibling for a certain condition. If the condition is met, the - /// sibling is returned. Otherwise this method looks amongst the sibling's right-side - /// descendants for any node returning `true` at the evaluation closure. - /// - /// The search bails if the bail closure returns `true` for either the sibling or its - /// descendants before a matching node is found. - /// - /// When a match is found, it's pushed up to the level of the receiver. - /// - /// - Parameters: - /// - childIndex: the index of the child to find the sibling of. - /// - evaluation: the closure that will evaluate the nodes for a matching result. - /// - bail: the closure to evaluate if the search must bail. - /// - /// - Returns: The requested node, if one is found, or `nil`. - /// - fileprivate func pushUp(siblingOrDescendantAtLeftSideOf childIndex: Int, evaluatedBy evaluation: ((T) -> Bool), bailIf bail: ((T) -> Bool) = { _ in return false }) -> T? { - - guard let theSibling: T = sibling(leftOf: childIndex) else { - return nil - } - - if evaluation(theSibling) { - return theSibling - } - - guard !bail(theSibling) else { - return nil - } - - guard let element = theSibling as? ElementNode else { - return nil - } - - return element.pushUp(rightSideDescendantEvaluatedBy: evaluation, bailIf: bail) - } - - /// Pushes up to the level of the receiver any left-side descendant that evaluates - /// to `true`. - /// - /// - Parameters: - /// - evaluationClosure: the closure that will be used to evaluate all descendants. - /// - bail: the closure that will be used to evaluate if the descendant search must - /// bail. - /// - /// - Returns: if any matching descendant is found, this method will return the requested - /// node after being pushed all the way up, or `nil` if no matching descendant is - /// found. - /// - func pushUp(leftSideDescendantEvaluatedBy evaluationClosure: ((T) -> Bool), bailIf bail: ((T) -> Bool) = { _ in return false }) -> T? { - - guard let node = find(leftSideDescendantEvaluatedBy: evaluationClosure, bailIf: bail) else { - return nil - } - - guard let element = node as? ElementNode else { - return nil - } - - guard let finalParent = parent else { - assertionFailure("Cannot call this method on a node that doesn't have a parent.") - return nil - } - - while element.parent != nil && element.parent != finalParent { - element.pushUp(left: true) - } - - return node - } - - /// Evaluates the right sibling for a certain condition. If the condition is met, the - /// sibling is returned. Otherwise this method looks amongst the sibling's left-side - /// descendants for any node returning `true` at the evaluation closure. - /// - /// The search bails if the bail closure returns `true` for either the sibling or its - /// descendants before a matching node is found. - /// - /// When a match is found, it's pushed up to the level of the receiver. - /// - /// - Parameters: - /// - childIndex: the index of the child to find the sibling of. - /// - evaluation: the closure that will evaluate the nodes for a matching result. - /// - bail: the closure to evaluate if the search must bail. - /// - /// - Returns: The requested node, if one is found, or `nil`. - /// - fileprivate func pushUp(siblingOrDescendantAtRightSideOf childIndex: Int, evaluatedBy evaluation: ((T) -> Bool), bailIf bail: ((T) -> Bool) = { _ in return false }) -> T? { - - guard let theSibling: T = sibling(rightOf: childIndex) else { - return nil - } - - if evaluation(theSibling) { - return theSibling - } - - guard !bail(theSibling) else { - return nil - } - - guard let element = theSibling as? ElementNode else { - return nil - } - - return element.pushUp(leftSideDescendantEvaluatedBy: evaluation, bailIf: bail) - } - - /// Pushes up to the level of the receiver any right-side descendant that evaluates - /// to `true`. - /// - /// - Parameters: - /// - evaluationClosure: the closure that will be used to evaluate all descendants. - /// - bail: the closure that will be used to evaluate if the descendant search must - /// bail. - /// - /// - Returns: if any matching descendant is found, this method will return the requested - /// node after being pushed all the way up, or `nil` if no matching descendant is - /// found. - /// - func pushUp(rightSideDescendantEvaluatedBy evaluationClosure: ((T) -> Bool), bailIf bail: ((T) -> Bool) = { _ in return false }) -> T? { - - guard let node = find(rightSideDescendantEvaluatedBy: evaluationClosure, bailIf: bail) else { - return nil - } - - guard let element = node as? ElementNode else { - return nil - } - - guard let finalParent = parent else { - assertionFailure("Cannot call this method on a node that doesn't have a parent.") - return nil - } - - while element.parent != nil && element.parent != finalParent { - element.pushUp(left: false) - } - - return node - } - - // MARK: - EditableNode - - override func deleteCharacters(inRange range: NSRange) { - if range.location == 0 && range.length == length() { - removeFromParent() - } else { - let childrenAndIntersections = childNodes(intersectingRange: range) - - for (child, intersection) in childrenAndIntersections { - child.deleteCharacters(inRange: intersection) - } - } - } - - /// Inserts the specified text in a new `TextNode` at the specified index. If any of the siblings are - /// of class `TextNode`, this method will append or prepend the text to them instead. - /// - /// Outside classes should call `insert(string:, atLocation:)` instead. - /// - /// This method is just a handler for some specific scenarios handled by that method. - /// - /// - Parameters: - /// - string: the string to insert in a new `TextNode`. - /// - index: the index where the next `TextNode` will be inserted. - /// - func insert(_ string: String, atNodeIndex index: Int) { - - guard index <= children.count else { - fatalError("The specified index is outside the range of possible indexes for insertion.") - } - - let previousIndex = index - 1 - - if previousIndex >= 0, let previousTextNode = children[previousIndex] as? TextNode { - previousTextNode.append(string) - } else if index < children.count, let nextTextNode = children[index] as? TextNode { - nextTextNode.prepend(string) - } else { - // It's not great having to set empty text and then append text to it. The reason - // we're doing it here is that if the text contains line-breaks, they will only - // be processed as BR tags if the text is set after construction. - // - // This code can be improved but this "hack" will allow us to postpone the necessary - // code restructuration. - // - let textNode = TextNode(text: "", editContext: editContext) - insert(textNode, at: index) - textNode.append(string) - } - } - - /// Inserts the specified string at the specified location. - /// - func insert(_ string: String, atLocation location: Int) { - - let blockLevelElementsAndIntersections = lowestBlockLevelElements(intersectingRange: NSRange(location: location, length: 0)) - - guard blockLevelElementsAndIntersections.count != 0 else { - if location == 0 { - // It's not great having to set empty text and then append text to it. The reason - // we're doing it here is that if the text contains line-breaks, they will only - // be processed as BR tags if the text is set after construction. - // - // This code can be improved but this "hack" will allow us to postpone the necessary - // code restructuration. - // - let textNode = TextNode(text: "", editContext: editContext) - append(textNode) - textNode.append(string) - } else { - fatalError("If there are no child nodes, the insert location has to be zero.") - } - - return - } - - let element = blockLevelElementsAndIntersections[0].element - let intersection = blockLevelElementsAndIntersections[0].intersection - - let indexAndIntersection = element.indexOf(childNodeIntersecting: intersection.location) - - let childIndex = indexAndIntersection.index - let childIntersection = indexAndIntersection.intersection - - let child = element.children[childIndex] - var insertionIndex: Int - - if childIntersection == 0 { - insertionIndex = childIndex - } else { - - if childIntersection < child.length() { - child.split(atLocation: childIntersection) - } - - insertionIndex = childIndex + 1 - } - - element.insert(string, atNodeIndex: insertionIndex) - } - - override func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool = true) { - let childrenAndIntersections = childNodes(intersectingRange: range) - let preferRightNode = !preferLeftNode - var textInserted = false - - assert(range.location == 0 || childrenAndIntersections.count > 0) - - guard childrenAndIntersections.count > 0 else { - insert(string, atLocation: 0) - return - } - - for (index, childAndIntersection) in childrenAndIntersections.enumerated() { - let child = childAndIntersection.child - let intersection = childAndIntersection.intersection - - guard !textInserted else { - child.deleteCharacters(inRange: intersection) - continue - } - - if intersection.location == 0 { - guard index == 0 || preferRightNode else { - if intersection.length > 0 { - child.deleteCharacters(inRange: intersection) - } - continue - } - - if preferLeftNode || mustInterruptStyleAtEdges(forNode: child) { - let childIndex = indexOf(childNode: child) - - child.deleteCharacters(inRange: intersection) - insert(string, atNodeIndex: childIndex) - } else { - child.replaceCharacters(inRange: intersection, withString: string, preferLeftNode: preferLeftNode) - } - } else if intersection.location + intersection.length == child.length() { - guard index == childrenAndIntersections.count - 1 || preferLeftNode else { - if intersection.length > 0 { - child.deleteCharacters(inRange: intersection) - } - continue - } - - if preferRightNode || mustInterruptStyleAtEdges(forNode: child) { - let childIndex = indexOf(childNode: child) + 1 - - child.deleteCharacters(inRange: intersection) - insert(string, atNodeIndex: childIndex) - } else { - child.replaceCharacters(inRange: intersection, withString: string, preferLeftNode: preferLeftNode) - } - } else { - child.replaceCharacters(inRange: intersection, withString: string, preferLeftNode: preferLeftNode) - } - - textInserted = true - } - } - - /// Replace characters in targetRange by a node with the name in nodeName and attributes - /// - /// - parameter targetRange: The range to replace - /// - parameter descriptor: The descriptor for the element to replace the text with. - /// - func replaceCharacters(in targetRange: NSRange, with descriptor: NodeDescriptor) { - - guard let textNode = lowestTextNodeWrapping(targetRange) else { - return - } - - let absoluteLocation = textNode.absoluteLocation() - let localRange = NSRange(location: targetRange.location - absoluteLocation, length: targetRange.length) - textNode.split(forRange: localRange) - - let node: Node - if let descriptor = descriptor as? ElementNodeDescriptor { - node = ElementNode(descriptor: descriptor, editContext: editContext) - } else if let descriptor = descriptor as? CommentNodeDescriptor { - node = CommentNode(text: descriptor.comment, editContext: editContext) - } else { - fatalError("Unsupported Node Descriptor") - } - - - guard let index = textNode.parent?.children.index(of: textNode) else { - assertionFailure("Can't remove a node that's not a child.") - return - } - - guard let textNodeParent = textNode.parent else { - return - } - - textNodeParent.insert(node, at: index) - textNodeParent.remove(textNode) - } - - override func split(atLocation location: Int) { - let length = self.length() - - guard location != 0 && location != length else { - // Nothing to split, move along... - return - } - - guard location > 0 && location < length else { - assertionFailure("Specified range is out-of-bounds.") - return - } - - guard let parent = parent, - let nodeIndex = parent.children.index(of: self) else { - assertionFailure("Can't split a node without a parent.") - return - } - - let postNodes = splitChildren(after: location) - - if postNodes.count > 0 { - let newElement = ElementNode(name: name, attributes: attributes, children: postNodes, editContext: editContext) - - parent.insert(newElement, at: nodeIndex + 1) - } - } - - - /// Splits this node according to the specified range. - /// - /// - Parameters: - /// - range: the range to use for splitting this node. All nodes before and after the specified range will - /// be inserted in clones of this node. All child nodes inside the range will be kept inside this node. - /// - override func split(forRange range: NSRange) { - - guard range.location >= 0 && range.location + range.length <= length() else { - assertionFailure("Specified range is out-of-bounds.") - return - } - - guard let parent = parent, - let nodeIndex = parent.children.index(of: self) else { - assertionFailure("Can't split a node without a parent.") - return - } - - let postNodes = splitChildren(after: range.location + range.length) - - if postNodes.count > 0 { - let newElement = ElementNode(name: name, attributes: attributes, children: postNodes, editContext: editContext) - - parent.insert(newElement, at: nodeIndex + 1) - } - - let preNodes = splitChildren(before: range.location) - - if preNodes.count > 0 { - let newElement = ElementNode(name: name, attributes: attributes, children: preNodes, editContext: editContext) - - parent.insert(newElement, at: nodeIndex) - } - } - - // MARK: - Wrapping - - func unwrap(fromElementsNamed elementNames: [String]) { - if elementNames.contains(name) { - unwrapChildren() - } - } - - func unwrapChildren(first amount: Int) { - assert(children.count >= amount) - - guard let parent = parent else { - assertionFailure("Cannot execute this method if a parent isn't set.") - return - } - - let myIndex = parent.indexOf(childNode: self) - - for _ in 0...(amount - 1) { - parent.insert(children[0], at: myIndex) - } - - if children.count == 0 { - removeFromParent() - } - } - - /// Unwraps the receiver's children from the receiver. - /// - @discardableResult - func unwrapChildren(undoManager: UndoManager? = nil) -> [Node] { - - let result = children - - if let parent = parent { - parent.replace(child: self, with: children) - } else { - for child in children { - child.parent = nil - } - - children.removeAll() - } - - return result - } - - func unwrapChildren(_ children: [Node], fromElementsNamed elementNames: [String]) { - - for child in children { - - guard let childElement = child as? ElementNode else { - continue - } - - childElement.unwrap(fromElementsNamed: elementNames) - } - } - - /// Wraps the specified children nodes in a newly created element with the specified name. - /// The newly created node will be inserted at the position of `children[0]`. - /// - /// - Parameters: - /// - children: the children nodes to wrap in a new node. - /// - elementDescriptor: the descriptor for the element to wrap the children in. - /// - /// - Returns: the newly created `ElementNode`. - /// - @discardableResult - func wrap(children selectedChildren: [Node], inElement elementDescriptor: ElementNodeDescriptor) -> ElementNode { - - guard selectedChildren.count > 0 else { - assertionFailure("Avoid calling this method with no nodes.") - return ElementNode(descriptor: elementDescriptor, editContext: editContext) - } - - guard let firstNodeIndex = children.index(of: selectedChildren[0]) else { - fatalError("A node's parent should contain the node. Review the child/parent updating logic.") - } - - guard let lastNodeIndex = children.index(of: selectedChildren[selectedChildren.count - 1]) else { - fatalError("A node's parent should contain the node. Review the child/parent updating logic.") - } - - let evaluation = { (node: ElementNode) -> Bool in - return node.name == elementDescriptor.name - } - - let bailEvaluation = { (node: ElementNode) -> Bool in - return node.isBlockLevelElement() - } - - // First get the right sibling because if we do it the other round, lastNodeIndex will - // be modified before we access it. - // - let rightSibling = pushUp(siblingOrDescendantAtRightSideOf: lastNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) - let leftSibling = pushUp(siblingOrDescendantAtLeftSideOf: firstNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) - - var childrenToWrap = selectedChildren - var result: ElementNode? - - if let sibling = rightSibling { - sibling.prepend(childrenToWrap) - childrenToWrap = sibling.children - - result = sibling - } - - if let sibling = leftSibling { - sibling.append(childrenToWrap) - childrenToWrap = sibling.children - - result = sibling - - if let rightSibling = rightSibling, rightSibling.children.count == 0 { - rightSibling.removeFromParent() - } - } - - if let result = result { - return result - } else { - let newNode = ElementNode(descriptor: elementDescriptor, children: childrenToWrap, editContext: editContext) - - children.insert(newNode, at: firstNodeIndex) - newNode.parent = self - - return newNode - } - } - - // MARK: - Editing behavior - - private func mustInterruptStyleAtEdges(forNode node: Node) -> Bool { - guard !(node is TextNode) else { - return false - } - - guard let elementNode = node as? ElementNode, - let elementType = StandardElementType(rawValue: elementNode.name) else { - return true - } - - return ElementNode.elementsThatInterruptStyleAtEdges.contains(elementType) - } - - // MARK: - Undo Support - - private func registerUndoForRemove(_ child: Node) { - - guard let editContext = editContext else { - return - } - - guard let index = children.index(of: child) else { - assertionFailure("The specified node is not one of this node's children.") - return - } - - editContext.undoManager.registerUndo(withTarget: self) { [weak self] target in - self?.children.insert(child, at: index) - } - } - } - - - class RootNode: ElementNode { - - static let name = "aztec.htmltag.rootnode" - - override var parent: Libxml2.ElementNode? { - get { - return nil - } + // MARK: - Initializers - set { - } + init(children: [Node]) { + super.init(name: type(of: self).name, attributes: [], children: children) } - // MARK: - CustomReflectable - - override public var customMirror: Mirror { - get { - return Mirror(self, children: ["name": name, "children": children]) - } - } - - // MARK: - Initializers + // MARK: - Overriden Methods - init(children: [Node], editContext: EditContext? = nil) { - super.init(name: type(of: self).name, attributes: [], children: children, editContext: editContext) + override func isSupportedByEditor() -> Bool { + return true } } } diff --git a/Aztec/Classes/Libxml2/DOM/Data/LeafNode.swift b/Aztec/Classes/Libxml2/DOM/Data/LeafNode.swift deleted file mode 100644 index 2f587f255..000000000 --- a/Aztec/Classes/Libxml2/DOM/Data/LeafNode.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// Represents any node that can be a leaf in the DOM tree. -/// -protocol LeafNode { - - /// Returns the text representation of the node. - /// - func text() -> String -} diff --git a/Aztec/Classes/Libxml2/DOM/Data/Node.swift b/Aztec/Classes/Libxml2/DOM/Data/Node.swift index f4eb5b6cc..0c1994dde 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/Node.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/Node.swift @@ -4,7 +4,7 @@ extension Libxml2 { /// Base class for all node types. /// - class Node: Equatable, CustomReflectable { + class Node: Equatable, CustomReflectable, Hashable { let name: String @@ -16,25 +16,12 @@ extension Libxml2 { /// Parent-node-reference setter and getter, with undo support. /// - var parent: ElementNode? { - get { - return rawParent - } - - set { - registerUndoForParentChange() - rawParent = newValue - } - } + var parent: ElementNode? // MARK: - Properties: Editing traits var canEditTextRepresentation: Bool = true - // MARK: - Properties: Edit Context - - let editContext: EditContext? - // MARK: - CustomReflectable public var customMirror: Mirror { @@ -42,235 +29,121 @@ extension Libxml2 { return Mirror(self, children: ["name": name, "parent": parent as Any]) } } - - // MARK: - Initializers - - init(name: String, editContext: EditContext? = nil) { - self.name = name - self.editContext = editContext - } - func range() -> NSRange { - return NSRange(location: 0, length: length()) - } - // MARK: - Override in Subclasses + // MARK - Hashable - /// Override. - /// - func length() -> Int { - assertionFailure("This method should always be overridden.") - return 0 - } - - /// Override. - /// - func text() -> String { - assertionFailure("This method should always be overridden.") - return "" + public var hashValue: Int { + return name.hashValue } - /// Finds the absolute location of a node inside a tree. - func absoluteLocation() -> Int { - var currentParent = self.parent - var currentNode = self - var absoluteLocation = 0 - while currentParent != nil { - let certainParent = currentParent! - for child in certainParent.children { - if child !== currentNode { - absoluteLocation += child.length() - } else { - currentNode = certainParent - currentParent = certainParent.parent - break - } - } - } - return absoluteLocation + // MARK: - Initializers + + init(name: String) { + self.name = name } // MARK: - DOM Queries - /// Retrieve all element nodes between the receiver and the root node. - /// The root node is included in the results. The receiver is only included if it's an - /// element node. - /// - /// - Parameters: - /// - interruptAtBlockLevel: whether the method should interrupt if it finds a - /// block-level element. - /// - /// - Returns: an ordered array of nodes. Element zero is the receiver if it's an element - /// node, otherwise its the receiver's parent node. The last element is the root - /// node. + func isLastIn(blockLevelElement element: ElementNode) -> Bool { + return element.isBlockLevelElement() && element.children.last === self + } + + /// Checks if the receiver is the last node in its parent. + /// Empty text nodes are filtered to avoid false positives. /// - func elementNodesToRoot(interruptAtBlockLevel: Bool = false) -> [ElementNode] { - var nodes = [ElementNode]() - var currentNode = self.parent + func isLastInParent() -> Bool { - if let elementNode = self as? ElementNode { - nodes.append(elementNode) + guard let parent = parent else { + return true } - while let node = currentNode { - nodes.append(node) - - if interruptAtBlockLevel && node.isBlockLevelElement() { - break + // We are filtering empty text nodes from being considered the last node in our + // parent node. + // + let lastMatchingChildInParent = parent.lastChild(matching: { node -> Bool in + guard let textNode = node as? TextNode, + textNode.length() == 0 else { + return true } - currentNode = node.parent - } + return false + }) - return nodes + return self === lastMatchingChildInParent } - /// This method returns the first `ElementNode` in common between the receiver and - /// the specified input parameter, going up both branches. + /// Checks if the receiver is the last node in the tree. /// - /// - Parameters: - /// - node: the algorythm will search for the parent nodes of the receiver, and this - /// input `TextNode`. - /// - interruptAtBlockLevel: whether the search should stop when a block-level - /// element has been found. + /// - Note: The verification excludes all child nodes, since this method only cares about + /// siblings and parents in the tree. /// - /// - Returns: the first element node in common, or `nil` if none was found. - /// - func firstElementNodeInCommon(withNode node: Node, interruptAtBlockLevel: Bool = false) -> ElementNode? { - let myParents = elementNodesToRoot(interruptAtBlockLevel: interruptAtBlockLevel) - let hisParents = node.elementNodesToRoot(interruptAtBlockLevel: interruptAtBlockLevel) + func isLastInTree() -> Bool { - for currentParent in hisParents { - if myParents.contains(currentParent) { - return currentParent - } + guard let parent = parent else { + return true } - return nil + return isLastInParent() && parent.isLastInTree() } - func isLastIn(blockLevelElement element: ElementNode) -> Bool { - return element.isBlockLevelElement() && element.children.last == self - } + /// Checks if the receiver is the last node in a block-level ancestor. + /// + /// - Note: The verification excludes all child nodes, since this method only cares about + /// siblings and parents in the tree. + /// + func isLastInBlockLevelAncestor() -> Bool { - func isLastInBlockLevelElement() -> Bool { guard let parent = parent else { return false } - guard !isLastIn(blockLevelElement: parent) else { - return true - } - - let index = parent.indexOf(childNode: self) + return isLastInParent() && + (parent.isBlockLevelElement() || parent.isLastInBlockLevelAncestor()) + } - if let sibling = parent.sibling(rightOf: index) { - if let siblingElement = sibling as? ElementNode { - return siblingElement.isBlockLevelElement() - } else { - return sibling.isLastInBlockLevelElement() - } + func hasRightBlockLevelSibling() -> Bool { + if let rightSibling = rightSibling() as? ElementNode, rightSibling.isBlockLevelElement() { + return true } else { - return parent.isLastInBlockLevelElement() + return false } } - // MARK: - DOM Modification - - /// Deletes all characters in the specified range. - /// - func deleteCharacters(inRange range: NSRange) { - assertionFailure("This method should always be overridden.") - } - - /// Removes this node from its parent, if it has one. - /// - func removeFromParent() { - parent?.remove(self) - } - - /// Replaces the specified range with a new string. - /// - /// - Parameters: - /// - range: the range of the original string to replace. - /// - string: the new string to replace the original text with. - /// - func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { - assertionFailure("This method should always be overridden.") - } + func isLastInAncestorEndingInBlockLevelSeparation() -> Bool { + guard let parent = parent else { + return false + } - /// Should split the node at the specified text location. The receiver will become the node before the specified - /// location and a new node will be created to contain whatever comes after it. - /// - /// - Parameters: - /// - location: the text location to split the node at. - /// - func split(atLocation location: Int) { - assertionFailure("This method should always be overridden.") + return parent.children.last === self + && (parent.isBlockLevelElement() + || parent.hasRightBlockLevelSibling() + || parent.isLastInAncestorEndingInBlockLevelSeparation()) } - /// Should split the node for the specified text range. The receiver will become the node - /// at the specified range. - /// - /// - Parameters: - /// - range: the range to use for splitting the node. + /// Retrieves the right sibling for a node. /// - func split(forRange range: NSRange) { - assertionFailure("This method should always be overridden.") - } - - /// Wraps this node in a new node with the specified name. Also takes care of updating - /// the parent and child node references. - /// - /// - Parameters: - /// - elementDescriptor: the descriptor for the element to wrap the receiver in. + /// - Returns: the right sibling, or `nil` if none exists. /// - /// - Returns: the newly created element. - /// - @discardableResult - func wrap(in elementDescriptor: ElementNodeDescriptor) -> ElementNode { - - let originalParent = parent - let originalIndex = parent?.children.index(of: self) - - let newNode = ElementNode(descriptor: elementDescriptor, editContext: editContext) + func rightSibling() -> Node? { - if let parent = originalParent { - guard let index = originalIndex else { - fatalError("If the node has a parent, the index should be obtainable.") - } - - parent.insert(newNode, at: index) + guard let parent = parent else { + return nil } - return newNode - } + let index = parent.children.index { node -> Bool in + return node === self + }! - /// Wraps the specified range in the specified element. - /// - /// - Parameters: - /// - range: the range to wrap. - /// - elementDescriptor: the element to wrap the range in. - /// - func wrap(in range: NSRange, inElement elementDescriptor: Libxml2.ElementNodeDescriptor) { - assertionFailure("This method should always be overridden.") + return parent.sibling(rightOf: index) } - - // MARK: - Undo support - - /// Registers an undo operation for an upcoming parent property change. + + // MARK: - DOM Modification + + /// Removes this node from its parent, if it has one. /// - private func registerUndoForParentChange() { - - guard let editContext = editContext else { - return - } - - let originalParent = rawParent - - editContext.undoManager.registerUndo(withTarget: self) { target in - target.parent = originalParent - } + func removeFromParent() { + parent?.remove(self) } } } @@ -278,5 +151,5 @@ extension Libxml2 { // MARK: - Node Equatable func ==(lhs: Libxml2.Node, rhs: Libxml2.Node) -> Bool { - return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + return lhs.name == rhs.name } diff --git a/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift b/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift index 2da1e5a9a..bc8658e67 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift @@ -47,11 +47,12 @@ extension Libxml2 { case tr = "tr" case u = "u" case ul = "ul" + case video = "video" /// Returns an array with all block-level elements. /// static var blockLevelNodeNames: [StandardElementType] { - return [.address, .blockquote, .div, .dl, .fieldset, .form, .h1, .h2, .h3, .h4, .h5, .h6, .hr, .li, .noscript, .ol, .p, .pre, .table, .ul] + return [.address, .blockquote, .div, .dl, .fieldset, .form, .h1, .h2, .h3, .h4, .h5, .h6, .hr, .li, .noscript, .ol, .p, .pre, .table, .tr, .td, .ul] } static func isBlockLevelNodeName(_ name: String) -> Bool { @@ -65,12 +66,14 @@ extension Libxml2 { var equivalentNames: [String] { get { switch self { + case .h1: return [self.rawValue] case .strong: return [self.rawValue, StandardElementType.b.rawValue] case .em: return [self.rawValue, StandardElementType.i.rawValue] case .b: return [self.rawValue, StandardElementType.strong.rawValue] case .i: return [self.rawValue, StandardElementType.em.rawValue] case .s: return [self.rawValue, StandardElementType.strike.rawValue, StandardElementType.del.rawValue] case .del: return [self.rawValue, StandardElementType.strike.rawValue, StandardElementType.s.rawValue] + case .strike: return [self.rawValue, StandardElementType.del.rawValue, StandardElementType.s.rawValue] default: return [self.rawValue] } @@ -85,8 +88,20 @@ extension Libxml2 { switch self { case .img: return NSAttributedString(string:String(UnicodeScalar(NSAttachmentCharacter)!), attributes: attributes) + case .video: + return NSAttributedString(string:String(UnicodeScalar(NSAttachmentCharacter)!), attributes: attributes) case .br: - return NSAttributedString(.newline, attributes: attributes) + // Since the user can type outside of paragraphs (or any block level element) we + // must ensure that when that happens, each line is treated as a separate paragraph. + // Otherwise the styles applied to each line will be overridden constantly + // by the lack of paragraph delimiters. + // + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, + paragraphStyle.properties.count > 0 { + return NSAttributedString(.lineSeparator, attributes: attributes) + } else { + return NSAttributedString(.lineFeed, attributes: attributes) + } case .hr: return NSAttributedString(string:String(UnicodeScalar(NSAttachmentCharacter)!), attributes: attributes) default: diff --git a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift index 602294c78..a2cee3731 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift @@ -3,9 +3,9 @@ import Foundation extension Libxml2 { /// Text nodes. Cannot have child nodes (for now, not sure if we will need them). /// - class TextNode: Node, LeafNode { + class TextNode: Node { - fileprivate var contents: String + var contents: String // MARK: - CustomReflectable @@ -17,392 +17,50 @@ extension Libxml2 { // MARK: - Initializers - init(text: String, editContext: EditContext? = nil) { + init(text: String) { contents = text - super.init(name: "text", editContext: editContext) + super.init(name: "text") } /// Node length. /// - override func length() -> Int { - let nsString = contents as NSString - return nsString.length - } - - // MARK: - Editing: Atomic Operations - - /// Appends the specified string. The input data is assumed to be sanitized, which means - /// this method does not perform verifications or cleanups on it. - /// - /// - Parameters: - /// - string: the string to append to the node. - /// - private func append(sanitizedString string: String) { - let nsString = string as NSString - registerUndoForAppend(appendedLength: nsString.length) - contents.append(string) - } - - /// Appends the specified components separated by the specified descriptor. - /// - /// - Parameters: - /// - components: an array of strings that will be appended. These will be separated - /// by the specified separator. - /// - separatorDescriptor: the node to use to separate the specified components. - /// - private func append(components: [String], separatedBy separatorDescriptor: ElementNodeDescriptor) { - guard let parent = parent else { - assertionFailure("This method cannot process newlines if the node's parent isn't set.") - return - } - - var insertionIndex = parent.indexOf(childNode: self) - - for (componentIndex, component) in components.enumerated() { - if componentIndex == 0 { - append(sanitizedString: component) - - insertionIndex = insertionIndex + 1 - } else { - let separator = ElementNode(descriptor: separatorDescriptor) - - parent.insert(separator, at: insertionIndex) - insertionIndex = insertionIndex + 1 - - if !component.isEmpty { - let textNode = TextNode(text: component, editContext: editContext) - - parent.insert(textNode, at: insertionIndex) - insertionIndex = insertionIndex + 1 - } - } - } - } - - /// Prepends the specified string. The input data is assumed to be sanitized, which means - /// this method does not perform verifications or cleanups on it. - /// - /// - Parameters: - /// - string: the string to prepend to the node. - /// - private func prepend(sanitizedString string: String) { - let nsString = string as NSString - registerUndoForPrepend(prependedLength: nsString.length) - contents = "\(string)\(contents)" - } - - /// Prepends the specified components separated by the specified descriptor. - /// - /// - Parameters: - /// - components: an array of strings that will be prepended. These will be separated - /// by the specified separator. - /// - separatorDescriptor: the node to use to separate the specified components. - /// - private func prepend(components: [String], separatedBy separatorDescriptor: ElementNodeDescriptor) { - guard let parent = parent else { - assertionFailure("This method cannot process newlines if the node's parent isn't set.") - return - } - - var insertionIndex = parent.indexOf(childNode: self) - - for (componentIndex, component) in components.enumerated() { - if componentIndex == components.count - 1 { - prepend(sanitizedString: component) - } else { - let textNode = TextNode(text: component, editContext: editContext) - let separator = ElementNode(descriptor: separatorDescriptor) - - parent.insert(textNode, at: insertionIndex) - parent.insert(separator, at: insertionIndex + 1) - - insertionIndex = insertionIndex + 2 - } - } - } - - /// Replaces the specified range with a new string. The input string is assumed to be - /// sanitized, which means this method does not perform verifications or cleanups on it. - /// - /// - Parameters: - /// - range: the range to replace. - /// - string: the string that will replace the specified range. - /// - private func replaceCharacters(inRange range: NSRange, withSanitizedString string: String) { - - guard let range = contents.rangeFromNSRange(range) else { - fatalError("The specified range is out of bounds.") - } - - registerUndoForReplaceCharacters(in: range, withString: string) - contents.replaceSubrange(range, with: string) - } - - /// Replaces the specified range with an array of string components separated by the - /// specified descriptor. - /// - /// This could be use, for example, to separate components with line breaks. - /// - /// - Parameters: - /// - range: the range to replace. - /// - components: an array of strings that will be inserted replacing the specified - /// range. These will be separated by the specified separator. - /// - separatorDescriptor: the node to use to separate the specified components. - /// - private func replaceCharacters(inRange range: NSRange, - withComponents components: [String], - separatedBy separatorDescriptor: ElementNodeDescriptor) { - - guard components.count > 0 else { - assertionFailure("Do not call this method with an empty list of components.") - return - } - - guard components.count > 1 else { - replaceCharacters(inRange: range, withSanitizedString: components[0]) - return - } - - deleteCharacters(inRange: range) - - if range.location == 0 { - prepend(components: components, separatedBy: separatorDescriptor) - } else if range.location == length() { - append(components: components, separatedBy: separatorDescriptor) - } else { - split(atLocation: range.location) - - guard let parent = parent else { - assertionFailure("This method cannot process newlines if the node's parent isn't set.") - return - } - - let leftNodeIndex = parent.indexOf(childNode: self) - let rightNodeIndex = leftNodeIndex + 1 - - assert(parent.children.count > rightNodeIndex) - - guard let rightNode = parent.children[rightNodeIndex] as? TextNode else { - assertionFailure("The right node should also be a TextNode. Review the logic.") - return - } - - var insertionIndex = parent.indexOf(childNode: self) + 1 - - for (index, component) in components.enumerated() { - if index == 0 { - append(sanitizedString: component) - - let separator = ElementNode(descriptor: separatorDescriptor) - - parent.insert(separator, at: insertionIndex) - insertionIndex = insertionIndex + 1 - } else if index == components.count - 1 { - rightNode.prepend(sanitizedString: component) - } else { - let textNode = TextNode(text: component, editContext: editContext) - let separator = ElementNode(descriptor: separatorDescriptor) - - parent.insert(textNode, at: insertionIndex) - parent.insert(separator, at: insertionIndex + 1) - - insertionIndex = insertionIndex + 2 - } - } - } - } - - // MARK: - EditableNode - - func append(_ string: String) { - let components = string.components(separatedBy: String(.newline)) - - if components.count == 1 { - append(sanitizedString: string) - } else { - append(components: components, separatedBy: ElementNodeDescriptor(elementType: .br)) - } + func length() -> Int { + return contents.characters.count } - override func deleteCharacters(inRange range: NSRange) { + // MARK: - Node - guard let range = contents.rangeFromNSRange(range) else { - fatalError("The specified range is out of bounds.") - } - - deleteCharacters(inRange: range) - } - - func deleteCharacters(inRange range: Range) { - - registerUndoForDeleteCharacters(inRange: range) - contents.removeSubrange(range) - } - - func prepend(_ string: String) { - let components = string.components(separatedBy: String(.newline)) - - if components.count == 1 { - prepend(sanitizedString: string) - } else { - prepend(components: components, separatedBy: ElementNodeDescriptor(elementType: .br)) - } - } - - override func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { - let components = string.components(separatedBy: String(.newline)) - - if components.count == 1 { - replaceCharacters(inRange: range, withSanitizedString: string) - } else { - replaceCharacters(inRange: range, withComponents: components, separatedBy: ElementNodeDescriptor(elementType: .br)) + /// Checks if the specified node requires a closing paragraph separator. + /// + func needsClosingParagraphSeparator() -> Bool { + guard length() > 0 else { + return false } - } - override func split(atLocation location: Int) { - - guard location != 0 && location != length() else { - // Nothing to split, move along... - - return - } - - guard location > 0 && location < length() else { - fatalError("Out of bounds!") + guard !hasRightBlockLevelSibling() else { + return true } - - guard - let index = text().indexFromLocation(location), - let parent = parent, - let nodeIndex = parent.children.index(of: self) else { - - fatalError("This scenario should not be possible. Review the logic.") - } - - let postRange = index ..< text().endIndex - - if postRange.lowerBound != postRange.upperBound { - let newNode = TextNode(text: text().substring(with: postRange), editContext: editContext) - - deleteCharacters(inRange: postRange) - parent.insert(newNode, at: nodeIndex + 1) - } + return !isLastInTree() && isLastInAncestorEndingInBlockLevelSeparation() } - - override func split(forRange range: NSRange) { - - guard let swiftRange = contents.rangeFromNSRange(range) else { - fatalError("This scenario should not be possible. Review the logic.") - } - guard let parent = parent, - let nodeIndex = parent.children.index(of: self) else { + // MARK - Hashable - fatalError("This scenario should not be possible. Review the logic.") - } - - let preRange = contents.startIndex ..< swiftRange.lowerBound - let postRange = swiftRange.upperBound ..< contents.endIndex - - if !postRange.isEmpty { - let newNode = TextNode(text: contents.substring(with: postRange), editContext: editContext) - - deleteCharacters(inRange: postRange) - parent.insert(newNode, at: nodeIndex + 1) - } - - if !preRange.isEmpty { - let newNode = TextNode(text: contents.substring(with: preRange), editContext: editContext) - - deleteCharacters(inRange: preRange) - parent.insert(newNode, at: nodeIndex) - } + override public var hashValue: Int { + return name.hashValue ^ contents.hashValue } - - /// Wraps the specified range inside a node with the specified name. - /// - /// - Parameters: - /// - targetRange: the range that must be wrapped. - /// - elementDescriptor: the descriptor for the element to wrap the range in. - /// - func wrap(range targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { - - guard !NSEqualRanges(targetRange, NSRange(location: 0, length: length())) else { - wrap(in: elementDescriptor) - return - } - - split(forRange: targetRange) - wrap(in: elementDescriptor) - } - // MARK: - LeafNode - override func text() -> String { + func text() -> String { return contents } - // MARK: - Undo support - - private func registerUndoForAppend(appendedLength: Int) { - - guard let editContext = editContext else { - return - } - - editContext.undoManager.registerUndo(withTarget: self) { target in - let endIndex = target.contents.endIndex - let range = target.contents.index(endIndex, offsetBy: -appendedLength)..) { - - guard let editContext = editContext else { - return - } - - let index = subrange.lowerBound - let removedContent = contents.substring(with: subrange).characters - - editContext.undoManager.registerUndo(withTarget: self) { target in - target.contents.insert(contentsOf: removedContent, at: index) - } - } - - private func registerUndoForPrepend(prependedLength: Int) { - - guard let editContext = editContext else { - return - } - - editContext.undoManager.registerUndo(withTarget: self) { target in - let startIndex = target.contents.startIndex - let range = startIndex ..< target.contents.index(startIndex, offsetBy: prependedLength) - - target.contents.removeSubrange(range) - } - } - - private func registerUndoForReplaceCharacters(in range: Range, withString string: String) { - - guard let editContext = editContext else { - return - } - - let index = range.lowerBound - let originalString = contents.substring(with: range) - - editContext.undoManager.registerUndo(withTarget: self) { target in - let newStringRange = index ..< target.contents.index(index, offsetBy: string.characters.count) - - target.contents.replaceSubrange(newStringRange, with: originalString) - } + // MARK: - Node Equatable + + static func ==(lhs: Libxml2.TextNode, rhs: Libxml2.TextNode) -> Bool { + return lhs.name == rhs.name && lhs.contents == rhs.contents } } } diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift deleted file mode 100644 index 14bf35acc..000000000 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift +++ /dev/null @@ -1,351 +0,0 @@ -import Foundation - -extension Libxml2 { - - /// Groups all the DOM editing logic. - /// - class DOMEditor: DOMLogic { - - typealias NodeMatchTest = (_ node: Node) -> Bool - - private let inspector: DOMInspector - - convenience override init(with rootNode: RootNode) { - self.init(with: rootNode, using: DOMInspector(with: rootNode)) - } - - init(with rootNode: RootNode, using inspector: DOMInspector) { - self.inspector = inspector - - super.init(with: rootNode) - } - - // MARK: - Node Introspection - - func canWrap(node: Node, in elementDescriptor: ElementNodeDescriptor) -> Bool { - - guard let element = node as? ElementNode else { - return true - } - - guard !(element is RootNode) else { - return false - } - - let receiverIsBlockLevel = element.isBlockLevelElement() - let newNodeIsBlockLevel = elementDescriptor.isBlockLevel() - - let canWrapReceiverInNewNode = newNodeIsBlockLevel || !receiverIsBlockLevel - - return canWrapReceiverInNewNode - } - - // MARK: - Wrapping Nodes - - /// Force-wraps the specified range inside a node with the specified properties. - /// - /// - Important: When the target range matches the receiver's full range we can just wrap the receiver in the - /// new node. We do need to check, however, that either: - /// - The new node is block-level, or - /// - The receiver isn't a block-level node. - /// - /// - Parameters: - /// - targetRange: the range that must be wrapped. - /// - elementDescriptor: the descriptor for the element to wrap the range in. - /// - func forceWrap(range targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { - forceWrap(element: rootNode, range: targetRange, inElement: elementDescriptor) - } - - /// Force-wraps the specified range inside a node with the specified properties. - /// - /// - Important: When the target range matches the receiver's full range we can just wrap the receiver in the - /// new node. We do need to check, however, that either: - /// - The new node is block-level, or - /// - The receiver isn't a block-level node. - /// - /// - Parameters: - /// - targetRange: the range that must be wrapped. - /// - elementDescriptor: the descriptor for the element to wrap the range in. - /// - func forceWrap(element: ElementNode, range targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { - - if NSEqualRanges(targetRange, element.range()) - && canWrap(node: element, in: elementDescriptor) { - element.wrap(in: elementDescriptor) - return - } - - forceWrapChildren(of: element, intersecting: targetRange, inElement: elementDescriptor) - } - - /// Force wraps child nodes intersecting the specified range inside new elements with the - /// specified properties. - /// - /// - Important: this is almost the same as - /// `wrapChildren(intersectingRange:, inNodeNamed:, withAttributes:)` but this - /// method doesn't check if the child nodes are block-level elements or not. - /// - /// - Parameters: - /// - targetRange: the range that must be wrapped. - /// - elementDescriptor: the descriptor for the element to wrap the range in. - /// - fileprivate func forceWrapChildren(of element: ElementNode, intersecting targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { - - assert(element.range().contains(range: targetRange)) - - let childNodesAndRanges = element.childNodes(intersectingRange: targetRange) - - guard childNodesAndRanges.count > 0 else { - // It's possible the range may not intersect any child node, if this node is adding - // any special characters for formatting purposes in visual mode. For instance some - // nodes add a newline character at their end. - // - return - } - - let firstChild = childNodesAndRanges[0].child - let firstChildIntersection = childNodesAndRanges[0].intersection - - if childNodesAndRanges.count == 1, - let elementNode = firstChild as? ElementNode { - - forceWrapChildren(of: elementNode, intersecting: firstChildIntersection, inElement: elementDescriptor) - return - } - - if !NSEqualRanges(firstChild.range(), firstChildIntersection) { - firstChild.split(forRange: firstChildIntersection) - } - - if childNodesAndRanges.count > 1 { - let lastChild = childNodesAndRanges[childNodesAndRanges.count - 1].child - let lastChildIntersection = childNodesAndRanges[childNodesAndRanges.count - 1].intersection - - if !NSEqualRanges(lastChild.range(), lastChildIntersection) { - lastChild.split(forRange: lastChildIntersection) - } - } - - let children = childNodesAndRanges.map({ (child: Node, intersection: NSRange) -> Node in - return child - }) - - element.wrap(children: children, inElement: elementDescriptor) - } - - /// Wraps the specified range inside a node with the specified properties. - /// - /// - Parameters: - /// - element: the element containing the specified range. - /// - targetRange: the range that must be wrapped. - /// - elementDescriptor: the descriptor for the element to wrap the range in. - /// - func wrap(_ element: ElementNode, range targetRange: NSRange, inElement elementDescriptor: Libxml2.ElementNodeDescriptor) { - - let mustFindLowestBlockLevelElements = !elementDescriptor.isBlockLevel() - - if mustFindLowestBlockLevelElements { - let elementsAndIntersections = element.lowestBlockLevelElements(intersectingRange: targetRange) - - for elementAndIntersection in elementsAndIntersections { - - let element = elementAndIntersection.element - let intersection = elementAndIntersection.intersection - - forceWrapChildren(of: element, intersecting: intersection, inElement: elementDescriptor) - } - } else { - forceWrap(element: element, range: targetRange, inElement: elementDescriptor) - } - } - - /// Wraps child nodes intersecting the specified range inside new elements with the - /// specified properties. - /// - /// - Parameters: - /// - targetRange: the range that must be wrapped. - /// - elementDescriptor: the descriptor for the element to wrap the range in. - /// - func wrapChildren(intersectingRange targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { - wrapChildren(of: rootNode, intersectingRange: targetRange, inElement: elementDescriptor) - } - - /// Wraps child nodes intersecting the specified range inside new elements with the - /// specified properties. - /// - /// - Parameters: - /// - element: the element containing the specified range. - /// - targetRange: the range that must be wrapped. - /// - elementDescriptor: the descriptor for the element to wrap the range in. - /// - func wrapChildren(of element: ElementNode, intersectingRange targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { - - let matchVerification: NodeMatchTest = { return $0 is ElementNode && elementDescriptor.matchingNames.contains($0.name) } - - element.enumerateFirstDescendants( - in: targetRange, - matching: matchVerification, - onMatchFound: nil, - onMatchNotFound: { [unowned self] range in - let mustFindLowestBlockLevelElements = !elementDescriptor.isBlockLevel() - - if mustFindLowestBlockLevelElements { - let elementsAndIntersections = element.lowestBlockLevelElements(intersectingRange: targetRange) - - for (element, intersection) in elementsAndIntersections { - // 0-length intersections are possible, but they make no sense in the context - // of wrapping content inside new elements. We should ignore zero-length - // intersections. - // - guard intersection.length > 0 else { - continue - } - - self.forceWrapChildren(of: element, intersecting: intersection, inElement: elementDescriptor) - } - } else { - self.forceWrapChildren(of: element, intersecting: targetRange, inElement: elementDescriptor) - } - }) - } - - // MARK: - Unwrapping Nodes - - /// Unwraps the specified range from nodes with the specified name. If there are multiple - /// nodes with the specified name, the range will be unwrapped from all of them. - /// - /// - Parameters: - /// - range: the range that must be unwrapped. - /// - elementNames: the names of the elements the range must be unwrapped from. - /// - /// - Todo: this method works with node names only for now. At some point we'll want to - /// modify this to be able to do more complex lookups. For instance we'll want - /// to be able to unwrapp CSS attributes, not just nodes by name. - /// - func unwrap(range: NSRange, fromElementsNamed elementNames: [String]) { - unwrap(rootNode, range: range, fromElementsNamed: elementNames) - } - - /// Unwraps the specified range from nodes with the specified name. If there are multiple - /// nodes with the specified name, the range will be unwrapped from all of them. - /// - /// - Parameters: - /// - element: the element containing the specified range. - /// - range: the range that must be unwrapped. - /// - elementNames: the names of the elements the range must be unwrapped from. - /// - /// - Todo: this method works with node names only for now. At some point we'll want to - /// modify this to be able to do more complex lookups. For instance we'll want - /// to be able to unwrapp CSS attributes, not just nodes by name. - /// - func unwrap(_ element: ElementNode, range: NSRange, fromElementsNamed elementNames: [String]) { - - guard element.children.count > 0 else { - return - } - - unwrapChildren(of: element, intersecting: range, fromElementsNamed: elementNames) - - if elementNames.contains(element.name) { - - let rangeEndLocation = range.location + range.length - - let myLength = element.length() - assert(range.location >= 0 && rangeEndLocation <= myLength, - "The specified range is out of bounds.") - - let elementDescriptor = ElementNodeDescriptor(name: element.name, attributes: element.attributes) - - if range.location > 0 { - let preRange = NSRange(location: 0, length: range.location) - wrap(element, range: preRange, inElement: elementDescriptor) - } - - if rangeEndLocation < myLength { - let postRange = NSRange(location: rangeEndLocation, length: myLength - rangeEndLocation) - wrap(element, range: postRange, inElement: elementDescriptor) - } - - element.unwrapChildren() - } - } - - /// Unwraps all child nodes from elements with the specified names. - /// - /// - Parameters: - /// - element: the element containing the specified range. - /// - range: the range we want to unwrap. - /// - elementNames: the name of the elements we want to unwrap the nodes from. - /// - func unwrapChildren(of element: ElementNode, intersecting range: NSRange, fromElementsNamed elementNames: [String]) { - if element.isBlockLevelElement() && element.text().isLastValidLocation(range.location) { - return - } - - let childNodesAndRanges = element.childNodes(intersectingRange: range) - assert(childNodesAndRanges.count > 0) - - for (child, range) in childNodesAndRanges { - guard let childElement = child as? ElementNode else { - continue - } - - unwrap(childElement, range: range, fromElementsNamed: elementNames) - } - } - - // MARK: - Merging Nodes - - /// Merges the siblings found separated at the specified location. Since the DOM is a tree - /// only two siblings can match this separator. - /// - /// - Parameters: - /// - location: the location that separates the siblings we're looking for. - /// - func mergeSiblings(separatedAt location: Int) { - guard let theSiblings = inspector.findSiblings(separatedAt: location) else { - return - } - - mergeSiblings(leftSibling: theSiblings.leftSibling, rightSibling: theSiblings.rightSibling) - } - - /// Merges the siblings found separated at the specified location. Since the DOM is a tree - /// only two siblings can match this separator. - /// - /// - Parameters: - /// - leftSibling: the left sibling to merge. - /// - rightSibling: the right sibling to merge. - /// - private func mergeSiblings(leftSibling: Node, rightSibling: Node) { - let finalRightNodes: [Node] - - if let rightElement = rightSibling as? ElementNode, - rightElement.isBlockLevelElement() { - - if rightElement.children.count > 0, - let rightChildElement = rightElement.children[0] as? ElementNode, - rightChildElement.isBlockLevelElement() { - - rightElement.unwrapChildren(first: 1) - - // This case was designed for lists. They need to unwrap the first list element - // from both the ul / ol and the li elements. - // - finalRightNodes = rightChildElement.unwrapChildren() - } else { - finalRightNodes = rightElement.unwrapChildren() - } - } else { - finalRightNodes = [rightSibling] - } - - if let leftElement = leftSibling as? ElementNode, - leftElement.isBlockLevelElement() { - - leftElement.append(finalRightNodes) - } - } - } -} diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMInspector.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMInspector.swift deleted file mode 100644 index 81358d23f..000000000 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMInspector.swift +++ /dev/null @@ -1,64 +0,0 @@ -extension Libxml2 { - /// Groups all the DOM inspection & node lookup logic. - /// - class DOMInspector: DOMLogic { - - /// Finds the two siblings separated at the specified location. - /// - //// - Parameters: - /// - location: the location that separates the two siblings. - /// - /// - Returns: the two siblings, if they exist, or `nil` in any other scenario. - /// - func findSiblings(separatedAt location: Int) -> (leftSibling: Node, rightSibling: Node)? { - return findSiblings(of: rootNode, separatedAt: location) - } - - /// Finds the two siblings separated at the specified location. - /// - //// - Parameters: - /// - location: the location that separates the two siblings. - /// - /// - Returns: the two siblings, if they exist, or `nil` in any other scenario. - /// - private func findSiblings(of element: ElementNode, separatedAt location: Int) -> (leftSibling: Node, rightSibling: Node)? { - - var leftSibling: Node? - var rightSibling: Node? - var childStartLocation = 0 - - for child in element.children { - - let childEndLocation = childStartLocation + child.length() - - // Ignore empty nodes - // - guard childStartLocation != childEndLocation else { - continue - } - - if location == childStartLocation { - rightSibling = child - break - } else if location == childEndLocation { - leftSibling = child - } else if location > childStartLocation && location < childEndLocation { - guard let childElement = child as? ElementNode else { - return nil - } - - return findSiblings(of: childElement, separatedAt: location - childStartLocation) - } - - childStartLocation = childEndLocation - } - - if let leftSibling = leftSibling, - let rightSibling = rightSibling { - return (leftSibling, rightSibling) - } else { - return nil - } - } - } -} diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMLogic.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMLogic.swift deleted file mode 100644 index 20c774454..000000000 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMLogic.swift +++ /dev/null @@ -1,11 +0,0 @@ -extension Libxml2 { - /// Groups DOM logic. - /// - class DOMLogic { - let rootNode: RootNode - - init(with rootNode: RootNode) { - self.rootNode = rootNode - } - } -} diff --git a/Aztec/Classes/Libxml2/DOMString.swift b/Aztec/Classes/Libxml2/DOMString.swift deleted file mode 100644 index 844a50358..000000000 --- a/Aztec/Classes/Libxml2/DOMString.swift +++ /dev/null @@ -1,599 +0,0 @@ -import UIKit - -extension Libxml2 { - - /// This class takes care of providing an interface for interacting with the DOM as if it Was - /// a string. - /// - /// Any requests made to this class are performed in its own queue (sometimes synchronously, - /// sometimes asynchronously). Public methods are resopnsible for queueing requests, while all - /// private methods MUST be synchronous. This is to ensure a simple design in which we are sure - /// we're not queueing an operation more than once. Private methods can be called without - /// having to figure out if they'll be queueing additional operations (they won't). - /// - class DOMString { - - private static let headerLevels: [StandardElementType] = [.h1, .h2, .h3, .h4, .h5, .h6] - - private lazy var editContext: EditContext = { - return EditContext(undoManager: self.domUndoManager) - }() - - private lazy var rootNode: RootNode = { - - let textNode = TextNode(text: "", editContext: self.editContext) - - return RootNode(children: [textNode], editContext: self.editContext) - }() - - private var parentUndoManager: UndoManager? - - var undoManager: UndoManager? { - get { - return parentUndoManager - } - - set { - stopObservingParentUndoManager() - parentUndoManager = newValue - startObservingParentUndoManager() - } - } - - /// The private undo manager for the DOM. This needs to be separated from the public undo - /// manager because it'll be running in a separate dispatch queue, and undo managers "break" - /// undo groups by run loops. - /// - /// This undo manager will respond to events in `parentUndoManager` to know when to execute - /// an undo operation. - /// - private var domUndoManager = UndoManager() - - /// Parent undo manager observer for the undo event. - /// - private var undoObserver: NSObjectProtocol? - - /// Parent undo manager observer for the beginGroup event. - /// - private var beginGroupObserver: NSObjectProtocol? - - /// The queue that will be used for all DOM interaction operations. - /// - let domQueue = DispatchQueue(label: "com.wordpress.domQueue", attributes: []) - - // MARK: - Properties: DOM Logic - - private lazy var domEditor: DOMEditor = { - return DOMEditor(with: self.rootNode) - }() - - // MARK: - Init & deinit - - deinit { - stopObservingParentUndoManager() - } - - // MARK: - Settings & Getting HTML - - /// Gets the HTML representation of the DOM. - /// - func getHTML() -> String { - - var result: String = "" - - domQueue.sync { [weak self] in - - guard let strongSelf = self else { - return - } - - let converter = Libxml2.Out.HTMLConverter() - result = converter.convert(strongSelf.rootNode) - } - - return result - } - - /// Sets the HTML for the DOM. - /// - /// - Parameters: - /// - html: the html to set. - /// - defaultFontDescriptor: the default font descriptor that will be used for the - /// output attributed string. - /// - /// - Returns: an attributed string representing the DOM contents. - /// - func setHTML(_ html: String, withDefaultFontDescriptor defaultFontDescriptor: UIFontDescriptor) -> NSAttributedString { - - let converter = HTMLToAttributedString(usingDefaultFontDescriptor: defaultFontDescriptor, editContext: editContext) - let output: (rootNode: RootNode, attributedString: NSAttributedString) - - do { - output = try converter.convert(html) - } catch { - fatalError("Could not convert the HTML.") - } - - domQueue.sync { - self.rootNode = output.rootNode - self.domEditor = DOMEditor(with: output.rootNode) - } - - return output.attributedString - } - - // MARK: - Editing - - /// Deletes a block-level elements separator at the specified location. - /// - /// - Parameters: - /// - location: the location of the block-level element separation we want to remove. - /// - func deleteBlockSeparator(at location: Int) { - performAsyncUndoable { [weak self] in - self?.deleteBlockSeparatorSynchronously(at: location) - } - } - - /// Replaces the specified range with a new string. - /// - /// - Parameters: - /// - range: the range of the original string to replace. - /// - string: the new string to replace the original text with. - /// - func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { - - let domHasModifications = range.length > 0 || !string.isEmpty - - if domHasModifications { - performAsyncUndoable { [weak self] in - self?.replaceCharactersSynchronously(inRange: range, withString: string, preferLeftNode: preferLeftNode) - } - } - } - - // MARK: - Editing: Synchronously - - /// Deletes a block-level elements separator at the specified location. - /// - /// - Parameters: - /// - location: the location of the block-level element separation we want to remove. - /// - private func deleteBlockSeparatorSynchronously(at location: Int) { - domEditor.mergeSiblings(separatedAt: location) - } - - /// Replaces the specified range with a new string. - /// - /// - Parameters: - /// - range: the range of the original string to replace. - /// - string: the new string to replace the original text with. - /// - private func replaceCharactersSynchronously(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { - rootNode.replaceCharacters(inRange: range, withString: string, preferLeftNode: preferLeftNode) - } - - // MARK: - Undo Manager - - /// We have some special setup we need to take care of before registering undo operations. - /// This method takes care of hooking up an undo operation in the client-provided undo - /// manager with an undo operation in the DOM undo manager. - /// - /// Parameters: - /// - task: the task to execute that contains undo operations. - /// - private func performAsyncUndoable(task: @escaping () -> ()) { - domQueue.async { [weak self] in - - guard let strongSelf = self else { - return - } - - strongSelf.domUndoManager.beginUndoGrouping() - task() - strongSelf.domUndoManager.endUndoGrouping() - } - } - - /// Make our private undo manager start observing the parent undo manager. This means - /// our private undo manager will basically be connected to the parent one to know when - /// to begin new undo groups, and perform undo operations. - /// - /// Redo operations don't need to be connected, as they can be executed completely through - /// the parent undo manager (and normal edits to the DOM). - /// - private func startObservingParentUndoManager() { - - undoObserver = NotificationCenter.default.addObserver(forName: .NSUndoManagerDidUndoChange, object: parentUndoManager, queue: nil) { [weak self] notification in - - guard let strongSelf = self else { - return - } - - if let undoManager = notification.object as? UndoManager, undoManager === strongSelf.parentUndoManager { - - let domUndoManager = strongSelf.domUndoManager - - domUndoManager.closeAllUndoGroups() - domUndoManager.undo() - } - } - - beginGroupObserver = NotificationCenter.default.addObserver(forName: .NSUndoManagerDidOpenUndoGroup, object: parentUndoManager, queue: nil) { [weak self] notification in - - guard let strongSelf = self else { - return - } - - if let undoManager = notification.object as? UndoManager, undoManager === strongSelf.parentUndoManager { - - let domUndoManager = strongSelf.domUndoManager - - domUndoManager.closeAllUndoGroups() - domUndoManager.beginUndoGrouping() - } - } - } - - /// Make our private undo manager stop observing the parent undo manager. - /// - private func stopObservingParentUndoManager() { - - if let beginGroupObserver = beginGroupObserver { - NotificationCenter.default.removeObserver(beginGroupObserver) - self.beginGroupObserver = nil - } - - if let undoObserver = undoObserver { - NotificationCenter.default.removeObserver(undoObserver) - self.undoObserver = nil - } - } - - // MARK: - Remove Styles - - func remove(element: StandardElementType, at range: NSRange){ - performAsyncUndoable { [weak self] in - self?.removeSynchronously(element: element, at: range) - } - } - - /// Disables bold from the specified range. - /// - /// - Parameters: - /// - range: the range to remove the style from. - /// - func removeBold(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeBoldSynchronously(spanning: range) - } - } - - /// Disables an image from the specified range. - /// - /// - Parameters: - /// - range: the range to remove the style from. - /// - func removeImage(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeImageSynchronously(spanning: range) - } - } - - /// Disables italic from the specified range. - /// - /// - Parameters: - /// - range: the range to remove the style from. - /// - func removeItalic(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeItalicSynchronously(spanning: range) - } - } - - /// Disables strikethrough from the specified range. - /// - /// - Parameters: - /// - range: the range to remove the style from. - /// - func removeStrikethrough(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeStrikethroughSynchronously(spanning: range) - } - } - - /// Disables underline from the specified range. - /// - /// - Parameters: - /// - range: the range to remove the style from. - /// - func removeUnderline(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeUnderlineSynchronously(spanning: range) - } - } - - /// Disables blockquote from the specified range. - /// - /// - Parameters: - /// - range: the range to remove the style from. - /// - func removeBlockquote(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeBlockquoteSynchronously(spanning: range) - } - } - - /// Disables link from the specified range - /// - /// - Parameter range: the range to remove - /// - func removeLink(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeSynchronously(element:.a, at: range) - } - } - - func removeHeader(_ headerLevel: Int, spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.removeHeaderSynchronously(headerLevel: headerLevel, spanning: range) - } - } - - // MARK: - Remove Styles: Synchronously - private func removeSynchronously(element: StandardElementType, at range: NSRange) { - - guard range.length > 0 else { - return - } - - domEditor.unwrap(range: range, fromElementsNamed: element.equivalentNames) - } - - private func removeBoldSynchronously(spanning range: NSRange) { - domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.b.equivalentNames) - } - - private func removeImageSynchronously(spanning range: NSRange) { - domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.img.equivalentNames) - } - - private func removeItalicSynchronously(spanning range: NSRange) { - domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.i.equivalentNames) - } - - private func removeStrikethroughSynchronously(spanning range: NSRange) { - domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.s.equivalentNames) - } - - private func removeUnderlineSynchronously(spanning range: NSRange) { - domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.u.equivalentNames) - } - - private func removeBlockquoteSynchronously(spanning range: NSRange) { - domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.blockquote.equivalentNames) - } - - private func removeHeaderSynchronously(headerLevel: Int, spanning range: NSRange) { - guard let elementType = elementTypeForHeaderLevel(headerLevel) else { - return - } - domEditor.unwrap(range: range, fromElementsNamed: elementType.equivalentNames) - } - - // MARK: - Apply Styles - - /// Applies bold to the specified range. - /// - /// - Parameters: - /// - range: the range to apply the style to. - /// - func applyBold(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.applyElement(.strong, spanning: range) - } - } - - /// Applies italic to the specified range. - /// - /// - Parameters: - /// - range: the range to apply the style to. - /// - func applyItalic(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.applyElement(.em, spanning: range) - } - } - - /// Applies strikethrough to the specified range. - /// - /// - Parameters: - /// - range: the range to apply the style to. - /// - func applyStrikethrough(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.applyElement(.del, spanning: range) - } - } - - /// Applies underline to the specified range. - /// - /// - Parameters: - /// - range: the range to apply the style to. - /// - func applyUnderline(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.applyElement(.u, spanning: range) - } - } - - /// Applies blockquote to the specified range. - /// - /// - Parameters: - /// - range: the range to apply the style to. - /// - func applyBlockquote(spanning range: NSRange) { - performAsyncUndoable { [weak self] in - self?.applyElement(.blockquote, spanning: range) - } - } - - /// Applies a link to the specified range - /// - /// - Parameters: - /// - url: the url to link to - /// - range: the range to apply the link - /// - func applyLink(_ url: URL?, spanning range: NSRange) { - var attributes: [Libxml2.Attribute] = [] - if let url = url { - attributes.append(Libxml2.StringAttribute(name: HTMLLinkAttribute.Href.rawValue, value: url.absoluteString)) - } - performAsyncUndoable { [weak self] in - self?.applyElement(.a, spanning: range, attributes: attributes) - } - } - - private func elementTypeForHeaderLevel(_ headerLevel: Int) -> StandardElementType? { - if headerLevel < 1 && headerLevel > DOMString.headerLevels.count { - return nil - } - return DOMString.headerLevels[headerLevel - 1] - } - - - func applyHeader(_ headerLevel:Int, spanning range:NSRange) { - guard let elementType = elementTypeForHeaderLevel(headerLevel) else { - return - } - performAsyncUndoable { [weak self] in - self?.applyElement(elementType, spanning: range) - } - } - - // MARK: - Images - - /// Replaces the specified range with a given image. - /// - /// - Parameters: - /// - range: the range to insert the image - /// - imageURL: the URL for the img src attribute - /// - func replace(_ range: NSRange, with imageURL: URL) { - performAsyncUndoable { [weak self] in - self?.replaceSynchronously(range, with: imageURL) - } - } - - private func replaceSynchronously(_ range: NSRange, with imageURL: URL) { - let imageURLString = imageURL.absoluteString - - let attributes = [Libxml2.StringAttribute(name:"src", value: imageURLString)] - let descriptor = ElementNodeDescriptor(elementType: .img, attributes: attributes) - - rootNode.replaceCharacters(in: range, with: descriptor) - } - - /// Replaces the specified range with a Horizontal Ruler Style. - /// - /// - Parameters: - /// - range: the range to apply the style to. - /// - func replaceWithHorizontalRuler(_ range: NSRange) { - performAsyncUndoable { [weak self] in - self?.replaceSynchronouslyWithHorizontalRulerStyle(range) - } - } - - private func replaceSynchronouslyWithHorizontalRulerStyle(_ range: NSRange) { - let descriptor = ElementNodeDescriptor(elementType: .hr) - - rootNode.replaceCharacters(in: range, with: descriptor) - } - - /// Replaces the specified range with a Comment. - /// - /// - Parameters: - /// - range: the range to apply the style to. - /// - comment: the comment to be stored. - /// - func replace(_ range: NSRange, with comment: String) { - performAsyncUndoable { [weak self] in - self?.replaceSynchronously(range, with: comment) - } - } - - private func replaceSynchronously(_ range: NSRange, with comment: String) { - let descriptor = CommentNodeDescriptor(comment: comment) - - rootNode.replaceCharacters(in: range, with: descriptor) - } - - - // MARK: - Styles to HTML elements - - /// Applies a standard HTML element to the specified range. - /// - /// Whenever applying a standard element type, use this method. - /// - /// - Parameters: - /// - elementType: the standard element type to apply. - /// - range: the range to apply the bold style to. - /// - fileprivate func applyElement(_ elementType: StandardElementType, spanning range: NSRange, attributes: [Attribute] = []) { - applyElement(elementType.rawValue, spanning: range, equivalentElementNames: elementType.equivalentNames, attributes: attributes) - } - - /// Applies an HTML element to the specified range. - /// - /// Use this method directly only when applying custom element types (non standard). - /// - /// - Parameters: - /// - elementName: the element name to apply - /// - range: the range to apply the bold style to. - /// - equivalentElementNames: equivalent element names to look for before applying - /// the specified one. - /// - fileprivate func applyElement(_ elementName: String, spanning range: NSRange, equivalentElementNames: [String], attributes: [Attribute] = []) { - - let elementDescriptor = ElementNodeDescriptor(name: elementName, attributes: attributes, matchingNames: equivalentElementNames) - domEditor.wrapChildren(intersectingRange: range, inElement: elementDescriptor) - } - - // MARK: - Candidates for removal - - func updateImage(spanning ranges: [NSRange], url: URL, size: TextAttachment.Size, alignment: TextAttachment.Alignment) { - performAsyncUndoable { [weak self] in - self?.updateImageSynchronously(spanning: ranges, url: url, size: size, alignment: alignment) - } - } - - // MARK: - Candidates for removal: Synchronously - - private func updateImageSynchronously(spanning ranges: [NSRange], url: URL, size: TextAttachment.Size, alignment: TextAttachment.Alignment) { - - for range in ranges { - let element = self.rootNode.lowestElementNodeWrapping(range) - - if element.name == StandardElementType.img.rawValue { - var components = [String]() - if let currentAttributes = element.valueForStringAttribute(named: "class") { - components = currentAttributes.components(separatedBy: CharacterSet.whitespaces) - components = components.filter({ (value) -> Bool in - return TextAttachment.Alignment.fromHTML(string: value.lowercased()) == nil && TextAttachment.Size.fromHTML(string: value.lowercased()) == nil - }) - - } - components.append(alignment.htmlString()) - components.append(size.htmlString()) - let classAttributes = components.joined(separator: " ") - element.updateAttribute(named: "class", value: classAttributes) - - if element.name == StandardElementType.img.rawValue { - element.updateAttribute(named: "src", value: url.absoluteString) - } - } - } - } - } -} diff --git a/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift b/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift index 1d558f030..bc71e72be 100644 --- a/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift +++ b/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift @@ -9,26 +9,33 @@ extension Libxml2 { /// class ElementNodeDescriptor: NodeDescriptor { let attributes: [Attribute] + let childDescriptor: ElementNodeDescriptor? let matchingNames: [String] - + let canMergeLeft: Bool + let canMergeRight: Bool + // MARK: - CustomReflectable - + public override var customMirror: Mirror { get { return Mirror(self, children: ["name": name, "attributes": attributes, "matchingNames": matchingNames]) } } - - init(name: String, attributes: [Attribute] = [], matchingNames: [String] = []) { + + init(name: String, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = [], matchingNames: [String] = [], canMergeLeft: Bool = true, canMergeRight: Bool = true) { self.attributes = attributes + self.canMergeLeft = canMergeLeft + self.canMergeRight = canMergeRight + self.childDescriptor = childDescriptor self.matchingNames = matchingNames + super.init(name: name) } - convenience init(elementType: StandardElementType, attributes: [Attribute] = []) { - self.init(name: elementType.rawValue, attributes: attributes, matchingNames: elementType.equivalentNames) + convenience init(elementType: StandardElementType, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = [], canMergeLeft: Bool = true, canMergeRight: Bool = true, endsWithVisualNewline: Bool = false) { + self.init(name: elementType.rawValue, childDescriptor: childDescriptor, attributes: attributes, matchingNames: elementType.equivalentNames, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) } - + // MARK: - Introspection func isBlockLevel() -> Bool { diff --git a/Aztec/Classes/Libxml2/EditContext.swift b/Aztec/Classes/Libxml2/EditContext.swift deleted file mode 100644 index c514275a2..000000000 --- a/Aztec/Classes/Libxml2/EditContext.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -extension Libxml2 { - /// The Edit Context class is useful for specifying whatever editing state and logic needs to - /// be shared across the full DOM tree. - /// - class EditContext { - - // MARK: - Properties: Undo support - - let undoManager: UndoManager - - // MARK: - Initializers - - init(undoManager: UndoManager) { - self.undoManager = undoManager - } - } -} diff --git a/Aztec/Classes/NSAttributedString/HTML/HTMLAttributeRepresentation.swift b/Aztec/Classes/NSAttributedString/HTML/HTMLAttributeRepresentation.swift new file mode 100644 index 000000000..13f29db6c --- /dev/null +++ b/Aztec/Classes/NSAttributedString/HTML/HTMLAttributeRepresentation.swift @@ -0,0 +1,61 @@ +import Foundation + + +// MARK: - HTMLAttributeRepresentation +// +class HTMLAttributeRepresentation: HTMLRepresentation, Equatable, CustomReflectable { + + typealias Attribute = Libxml2.Attribute + typealias StringAttribute = Libxml2.StringAttribute + + /// The element that owns this attribute. + /// + var element: HTMLElementRepresentation? + + /// The attribute name. + /// + let name: String + + /// The attribute's value, if present. + /// + let value: String? + + /// Initializes the HTMLAttributeRepresentation Instance + /// + init(for attribute: Attribute, in element: HTMLElementRepresentation? = nil) { + + self.element = element + name = attribute.name + + let stringAttribute = attribute as? StringAttribute + value = stringAttribute?.value + } + + + /// Returns the Attribute instance for the current representation + /// + func toAttribute() -> Attribute { + guard let value = value else { + return Attribute(name: name) + } + + return StringAttribute(name: name, value: value) + } + + + // MARK: - Equatable + + static func ==(lhs: HTMLAttributeRepresentation, rhs: HTMLAttributeRepresentation) -> Bool { + return type(of: lhs) == type(of: rhs) && lhs.name == rhs.name && lhs.value == rhs.value + } + + // MARK: - CustomReflectable + + public var customMirror: Mirror { + get { + let value = self.value ?? "" + + return Mirror(self, children: ["name": name, "value": value]) + } + } +} diff --git a/Aztec/Classes/NSAttributedString/HTML/HTMLElementRepresentation.swift b/Aztec/Classes/NSAttributedString/HTML/HTMLElementRepresentation.swift new file mode 100644 index 000000000..613314ad9 --- /dev/null +++ b/Aztec/Classes/NSAttributedString/HTML/HTMLElementRepresentation.swift @@ -0,0 +1,63 @@ +import Foundation + + +// MARK: - HTMLElementRepresentation +// +class HTMLElementRepresentation: HTMLRepresentation, Equatable, CustomReflectable { + + typealias ElementNode = Libxml2.ElementNode + + /// The element's name. + /// + let name: String + + /// The meta-data for the associated HTML attributes. + /// + var attributes = [HTMLAttributeRepresentation]() + + init(for element: ElementNode) { + name = element.name + + for attribute in element.attributes { + attributes.append(HTMLAttributeRepresentation(for: attribute)) + } + } + + func valueForAttribute(named name: String) -> String? { + for attribute in attributes { + guard attribute.name == name else { + continue + } + + return attribute.value + } + + return nil + } + + + /// Returns the ElementNode Instance for the current definition. + /// + func toNode() -> ElementNode { + let attributes = self.attributes.flatMap { representation in + return representation.toAttribute() + } + + return ElementNode(name: name, attributes: attributes, children: []) + } + + + // MARK: - Equatable + + static func ==(lhs: HTMLElementRepresentation, rhs: HTMLElementRepresentation) -> Bool { + return type(of: lhs) == type(of: rhs) && lhs.name == rhs.name && lhs.attributes == rhs.attributes + } + + // MARK: - CustomReflectable + + public var customMirror: Mirror { + get { + return Mirror(self, children: ["name": name, "attributes": attributes]) + } + } +} diff --git a/Aztec/Classes/NSAttributedString/HTML/HTMLRepresentation.swift b/Aztec/Classes/NSAttributedString/HTML/HTMLRepresentation.swift new file mode 100644 index 000000000..45a9bc587 --- /dev/null +++ b/Aztec/Classes/NSAttributedString/HTML/HTMLRepresentation.swift @@ -0,0 +1,4 @@ +import Foundation + +protocol HTMLRepresentation { +} diff --git a/Aztec/Classes/NSAttributedString/Styles/UnsupportedHTML.swift b/Aztec/Classes/NSAttributedString/Styles/UnsupportedHTML.swift new file mode 100644 index 000000000..fa4a89219 --- /dev/null +++ b/Aztec/Classes/NSAttributedString/Styles/UnsupportedHTML.swift @@ -0,0 +1,32 @@ +import Foundation + + +// MARK: - UnsupportedHTML NSAttributedString Attribute Name +// +let UnsupportedHTMLAttributeName = "UnsupportedHTMLAttributeName" + + +// MARK: - UnsupportedHTML +// +class UnsupportedHTML { + + /// Nodes not supported by the Editor (which will be re-serialized!!) + /// + private(set) var elements = [HTMLElementRepresentation]() + + /// Adds the specified Element Representation + /// + func add(element: HTMLElementRepresentation) { + elements.append(element) + } + + /// Removes the specified Element Representation + /// + func remove(element: HTMLElementRepresentation) { + guard let index = elements.index(where: { $0 == element }) else { + return + } + + elements.remove(at: index) + } +} diff --git a/Aztec/Classes/NSAttributedString/VisualOnlyAttribute.swift b/Aztec/Classes/NSAttributedString/VisualOnlyAttribute.swift deleted file mode 100644 index 507bdfe9e..000000000 --- a/Aztec/Classes/NSAttributedString/VisualOnlyAttribute.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -let VisualOnlyAttributeName = "Aztec.AttributeKeys.VisualOnlyAttributeName" // Value is ControlCharacterType - -/// The different visual-only element types. -/// -enum VisualOnlyElement: String { - case newline = "Aztec.ControlCharacterType.newline" - case zeroWidthSpace = "Aztec.ControlCharacterType.zeroWidthSpace" -} diff --git a/Aztec/Classes/NSAttributedString/VisualOnlyElementFactory.swift b/Aztec/Classes/NSAttributedString/VisualOnlyElementFactory.swift deleted file mode 100644 index f6610c4d7..000000000 --- a/Aztec/Classes/NSAttributedString/VisualOnlyElementFactory.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -/// This class has the only responsibility of creating visual-only elements. -/// -class VisualOnlyElementFactory { - - /// Creates a visual-only newline, inheriting the specified string attributes. - /// - /// - Parameters: - /// - inheritedAttributes: the string attributes that the element must inherit. - /// Defaults to `nil`. - /// - /// - Returns: the requested visual-only element. - /// - func newline(inheritingAttributes inheritedAttributes: [String:Any]? = nil) -> NSAttributedString { - var attributes = inheritedAttributes ?? [String:Any]() - - attributes[VisualOnlyAttributeName] = VisualOnlyElement.newline.rawValue - - return NSAttributedString(.newline, attributes: attributes) - } - - /// Creates a visual-only zero width space, inheriting the specified string attributes. - /// - /// - Parameters: - /// - inheritedAttributes: the string attributes that the element must inherit. - /// Defaults to `nil`. - /// - /// - Returns: the requested visual-only element. - /// - func zeroWidthSpace(inheritingAttributes inheritedAttributes: [String:Any]? = nil) -> NSAttributedString { - var attributes = inheritedAttributes ?? [String:Any]() - - attributes[VisualOnlyAttributeName] = VisualOnlyElement.zeroWidthSpace.rawValue - - return NSAttributedString(.zeroWidthSpace, attributes: attributes) - } -} diff --git a/Aztec/Classes/Processor/HTMLAttributes.swift b/Aztec/Classes/Processor/HTMLAttributes.swift new file mode 100644 index 000000000..d4dbb98d4 --- /dev/null +++ b/Aztec/Classes/Processor/HTMLAttributes.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Struct to represent the attributes of a shortcode +/// +public struct HTMLAttributes { + + /// Attributes that have a form key=value or key="value" or key='value' + public let named: [String: String] + + /// Attributes that have a form value "value" + public let unamed: [String] + + public init(named: [String: String], unamed: [String]) { + self.named = named + self.unamed = unamed + } +} + +/// A struct that parses attributes inside a shortcode and return the corresponding attributes object +/// +public struct HTMLAttributesParser { + + enum CaptureGroups: Int { + case all = 0 + case nameInDoubleQuotes + case valueInDoubleQuotes + case nameInSingleQuotes + case valueInSingleQuotes + case nameUnquoted + case valueUnquoted + case justValueQuoted + case justValueUnquoted + } + + /// Regular expression to detect attributes + /// This regular expression is reused from `shortcode_parse_atts()` + /// in `wp-includes/shortcodes.php`. + /// + /// Capture groups: + /// + /// 1. An attribute name, that corresponds to... + /// 2. a value in double quotes. + /// 3. An attribute name, that corresponds to... + /// 4. a value in single quotes. + /// 5. An attribute name, that corresponds to... + /// 6. an unquoted value. + /// 7. An attribute in double quotes. + /// 8. An unquoted attribute. + /// + static var attributesRegex: NSRegularExpression = { + let doubleQuotePattern = "((?:\\w|-)+)\\s*=\\s*\"([^\"]*)\"(?:\\s|$)" + let singleQuotePattern = "((?:\\w|-)+)\\s*=\\s*'([^']*)'(?:\\s|$)" + let noQuotePattern = "((?:\\w|-)+)\\s*=\\s*([^\\s'\"]+)(?:\\s|$)" + let attributesPattern: String = doubleQuotePattern + "|" + singleQuotePattern + "|" + noQuotePattern + "|\"([^\"]*)\"(?:\\s|$)|(\\S+)(?:\\s|$)" + return try! NSRegularExpression(pattern: attributesPattern, options: .caseInsensitive) + }() + + /// Parses the attributes from a string to the object + /// + /// - Parameter text: the text to where to find the attributes + /// - Returns: the ShortcodeAttributes parsed from the text + /// + public static func makeAttributes(in text:String) -> HTMLAttributes { + var namedAttributes = [String: String]() + var unamedAttributes = [String]() + + let attributesMatches = HTMLAttributesParser.attributesRegex.matches(in: text, options: [], range: text.nsRange(from: text.startIndex.. String? + + let tag: String + + /// Regular expression to detect attributes + /// Capture groups: + /// + /// 1. The element name + /// 2. The element argument list + /// 3. The self closing `/` + /// 4. The content of a element when it wraps some content. + /// 5. The closing tag. + /// + static func makeRegex(tag: String) -> NSRegularExpression { + let pattern = "\\<(\(tag))(?![\\w-])([^\\>\\/]*(?:\\/(?!\\>)[^\\>\\/]*)*?)(?:(\\/)\\>|\\>(?:([^\\<]*(?:\\<(?!\\/\\1\\>)[^\\<]*)*)(\\<\\/\\1\\>))?)" + let regex = try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + return regex + } + + enum CaptureGroups: Int { + case all = 0 + case name + case arguments + case selfClosingElement + case content + case closingTag + + static let allValues: [CaptureGroups] = [.all, .name, .arguments, .selfClosingElement, .content, .closingTag] + } + + public init(tag: String, replacer: @escaping HTMLReplacer) { + self.tag = tag + let regex = HTMLProcessor.makeRegex(tag: tag) + let regexReplacer = { (match: NSTextCheckingResult, text: String) -> String? in + guard match.numberOfRanges == CaptureGroups.allValues.count else { + return nil + } + var attributes = HTMLAttributes(named: [:], unamed: []) + if let attributesText = match.captureGroup(in:CaptureGroups.arguments.rawValue, text: text) { + attributes = HTMLAttributesParser.makeAttributes(in: attributesText) + } + + var type: HTMLElement.TagType = .single + if match.captureGroup(in:CaptureGroups.selfClosingElement.rawValue, text: text) != nil { + type = .selfClosing + } else if match.captureGroup(in:CaptureGroups.closingTag.rawValue, text: text) != nil { + type = .closed + } + + let content: String? = match.captureGroup(in:CaptureGroups.content.rawValue, text: text) + + let htmlElement = HTMLElement(tag: tag, attributes: attributes, type: type, content: content) + return replacer(htmlElement) + } + + super.init(regex: regex, replacer: regexReplacer) + } +} diff --git a/Aztec/Classes/Processor/Processor.swift b/Aztec/Classes/Processor/Processor.swift new file mode 100644 index 000000000..8b99741c4 --- /dev/null +++ b/Aztec/Classes/Processor/Processor.swift @@ -0,0 +1,43 @@ +import Foundation + +public protocol Processor { + func process(text: String) -> String +} + +open class RegexProcessor: Processor { + + public typealias ReplaceRegex = (NSTextCheckingResult, String) -> String? + + public let regex: NSRegularExpression + public let replacer: ReplaceRegex + + public init(regex: NSRegularExpression, replacer: @escaping ReplaceRegex) { + self.regex = regex + self.replacer = replacer + } + + public func process(text: String) -> String { + let matches = regex.matches(in: text, options: [], range: text.nsRange(from: text.startIndex.. String { + let mutableString = NSMutableString(string: text) + var offset = 0 + for (range, replacement) in matches { + let lengthBefore = mutableString.length + let offsetRange = NSRange(location: range.location + offset, length: range.length) + mutableString.replaceCharacters(in: offsetRange, with: replacement) + let lengthAfter = mutableString.length + offset += (lengthAfter - lengthBefore) + } + return mutableString as String + } +} diff --git a/Aztec/Classes/TextKit/Blockquote.swift b/Aztec/Classes/TextKit/Blockquote.swift deleted file mode 100644 index 4de94baa0..000000000 --- a/Aztec/Classes/TextKit/Blockquote.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -class Blockquote: NSObject, NSCoding { - public func encode(with aCoder: NSCoder) { - - } - - override public init() { - - } - - required public init?(coder aDecoder: NSCoder){ - - } - - static func ==(lhs: Blockquote, rhs: Blockquote) -> Bool { - return true - } -} - diff --git a/Aztec/Classes/TextKit/CommentAttachment.swift b/Aztec/Classes/TextKit/CommentAttachment.swift index c53eebc02..e27566ce8 100644 --- a/Aztec/Classes/TextKit/CommentAttachment.swift +++ b/Aztec/Classes/TextKit/CommentAttachment.swift @@ -2,32 +2,6 @@ import Foundation import UIKit -/// Comment Attachment's Delegate Helpers -/// -protocol CommentAttachmentDelegate: class { - - /// Returns the Bounds that should be used to render the current attachment. - /// - /// - Parameters: - /// - commentAttachment: The Comment to be rendered - /// - fragment: Current Line Fragment Bounds - /// - /// - Returns: CGRect specifiying the Attachment Bounds. - /// - func commentAttachment(_ commentAttachment: CommentAttachment, boundsForLineFragment fragment: CGRect) -> CGRect - - /// Returns the Image Representation for a given attachment. - /// - /// - Parameters: - /// - commentAttachment: The Comment to be rendered - /// - size: The Canvas Size - /// - /// - Returns: Optional UIImage instance, representing a given comment. - /// - func commentAttachment(_ commentAttachment: CommentAttachment, imageForSize size: CGSize) -> UIImage? -} - - /// Comment Attachments: Represents an HTML Comment /// open class CommentAttachment: NSTextAttachment { @@ -38,7 +12,7 @@ open class CommentAttachment: NSTextAttachment { /// Delegate /// - weak var delegate: CommentAttachmentDelegate? + weak var delegate: RenderableAttachmentDelegate? /// A message to display overlaid on top of the image /// @@ -49,6 +23,33 @@ open class CommentAttachment: NSTextAttachment { } + // MARK: - Initializers + + init() { + super.init(data: nil, ofType: nil) + } + + public required init?(coder aDecoder: NSCoder) { + super.init(data: nil, ofType: nil) + + guard let text = aDecoder.decodeObject(forKey: Keys.text) as? String else { + return + } + + self.text = text + } + + + // MARK: - NSCoder Methods + + open override func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + + aCoder.encode(text, forKey: Keys.text) + } + + + // MARK: - NSTextAttachmentContainer override open func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? { @@ -56,13 +57,13 @@ open class CommentAttachment: NSTextAttachment { return cachedImage } - glyphImage = delegate?.commentAttachment(self, imageForSize: imageBounds.size) + glyphImage = delegate?.attachment(self, imageForSize: imageBounds.size) return glyphImage } override open func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect { - guard let bounds = delegate?.commentAttachment(self, boundsForLineFragment: lineFrag) else { + guard let bounds = delegate?.attachment(self, boundsForLineFragment: lineFrag) else { assertionFailure("Could not determine Comment Attachment Size") return .zero } @@ -70,3 +71,13 @@ open class CommentAttachment: NSTextAttachment { return bounds } } + + +// MARK: - Private Helpers +// +private extension CommentAttachment { + + struct Keys { + static let text = "text" + } +} diff --git a/Aztec/Classes/TextKit/HTMLAttachment.swift b/Aztec/Classes/TextKit/HTMLAttachment.swift new file mode 100644 index 000000000..7484badfb --- /dev/null +++ b/Aztec/Classes/TextKit/HTMLAttachment.swift @@ -0,0 +1,106 @@ +import Foundation +import UIKit + + +/// HTML Attachments: Represents unknown HTML +/// +open class HTMLAttachment: NSTextAttachment { + + /// Internal Cached Image + /// + fileprivate var glyphImage: UIImage? + + /// Delegate + /// + weak var delegate: RenderableAttachmentDelegate? + + /// Name of the Root "Unknown" Tag + /// + open var rootTagName: String = "" { + didSet { + glyphImage = nil + } + } + + /// Raw Unknown HTML to be rendered + /// + open var rawHTML: String = "" { + didSet { + glyphImage = nil + } + } + + + // MARK: - Initializers + + init() { + super.init(data: nil, ofType: nil) + } + + public required init?(coder aDecoder: NSCoder) { + super.init(data: nil, ofType: nil) + + guard let rootTagName = aDecoder.decodeObject(forKey: Keys.rootTagName) as? String, + let rawHTML = aDecoder.decodeObject(forKey: Keys.rawHTML) as? String + else { + return + } + + self.rootTagName = rootTagName + self.rawHTML = rawHTML + } + + + /// Returns the Pretty Printed version of the contained HTML + /// + open func prettyHTML() -> String { + let inParser = Libxml2.In.HTMLConverter() + let outParser = Libxml2.Out.HTMLConverter(prettyPrint: true) + + let inNode = inParser.convert(rawHTML) + return outParser.convert(inNode) + } + + // MARK: - NSCoder Methods + + open override func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + + aCoder.encode(rootTagName, forKey: Keys.rootTagName) + aCoder.encode(rawHTML, forKey: Keys.rawHTML) + } + + + + // MARK: - NSTextAttachmentContainer + + override open func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? { + if let cachedImage = glyphImage, imageBounds.size.equalTo(cachedImage.size) { + return cachedImage + } + + glyphImage = delegate?.attachment(self, imageForSize: imageBounds.size) + + return glyphImage + } + + override open func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect { + guard let bounds = delegate?.attachment(self, boundsForLineFragment: lineFrag) else { + assertionFailure("Could not determine HTML Attachment Size") + return .zero + } + + return bounds + } +} + + +// MARK: - Private Helpers +// +private extension HTMLAttachment { + + struct Keys { + static let rootTagName = "rootTagName" + static let rawHTML = "rawHTML" + } +} diff --git a/Aztec/Classes/TextKit/HTMLStorage.swift b/Aztec/Classes/TextKit/HTMLStorage.swift new file mode 100644 index 000000000..cebbab7cf --- /dev/null +++ b/Aztec/Classes/TextKit/HTMLStorage.swift @@ -0,0 +1,153 @@ +import Foundation +import UIKit + + +// MARK: - NSTextStorage Implementation: Automatically colorizes all of the present HTML Tags. +// +open class HTMLStorage: NSTextStorage { + + /// Internal Storage + /// + private var textStore = NSMutableAttributedString(string: "", attributes: nil) + + /// Document's Font + /// + open var font: UIFont + + /// Color to be applied over HTML Comments + /// + open var commentColor = Styles.defaultCommentColor + + /// Color to be applied over HTML Tags + /// + open var tagColor = Styles.defaultTagColor + + /// Color to be applied over Quotes within HTML Tags + /// + open var quotedColor = Styles.defaultQuotedColor + + + + // MARK: - Initializers + + public init(defaultFont: UIFont) { + font = defaultFont + super.init() + } + + required public init?(coder aDecoder: NSCoder) { + fatalError() + } + + required public init(itemProviderData data: Data, typeIdentifier: String) throws { + fatalError("init(itemProviderData:typeIdentifier:) has not been implemented") + } + + + // MARK: - Overriden Methods + + override open var string: String { + return textStore.string + } + + override open func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [String : Any] { + guard textStore.length != 0 else { + return [:] + } + + return textStore.attributes(at: location, effectiveRange: range) + } + + override open func setAttributes(_ attrs: [String : Any]?, range: NSRange) { + beginEditing() + + textStore.setAttributes(attrs, range: range) + edited(.editedAttributes, range: range, changeInLength: 0) + + endEditing() + } + + override open func addAttribute(_ name: String, value: Any, range: NSRange) { + textStore.addAttribute(name, value: value, range: range) + } + + override open func removeAttribute(_ name: String, range: NSRange) { + textStore.removeAttribute(name, range: range) + } + + override open func replaceCharacters(in range: NSRange, with str: String) { + beginEditing() + + textStore.replaceCharacters(in: range, with: str) + edited([.editedAttributes, .editedCharacters], range: range, changeInLength: string.characters.count - range.length) + + endEditing() + } + + override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { + beginEditing() + + textStore.replaceCharacters(in: range, with: attrString) + edited([.editedAttributes, .editedCharacters], range: range, changeInLength: attrString.length - range.length) + + endEditing() + } + + override open func processEditing() { + colorizeHTML() + super.processEditing() + } +} + + +// MARK: - Private Helpers +// +private extension HTMLStorage { + + /// Colorizes all of the HTML Tags contained within this Storage + /// + func colorizeHTML() { + let fullStringRange = rangeOfEntireString + + removeAttribute(NSForegroundColorAttributeName, range: fullStringRange) + addAttribute(NSFontAttributeName, value: font, range: fullStringRange) + + let tags = RegExes.html.matches(in: string, options: [], range: fullStringRange) + for tag in tags { + addAttribute(NSForegroundColorAttributeName, value: tagColor, range: tag.range) + + let quotes = RegExes.quotes.matches(in: string, options: [], range: tag.range) + for quote in quotes { + addAttribute(NSForegroundColorAttributeName, value: quotedColor, range: quote.range) + } + } + + let comments = RegExes.comments.matches(in: string, options: [], range: fullStringRange) + for comment in comments { + addAttribute(NSForegroundColorAttributeName, value: commentColor, range: comment.range) + } + } +} + + +// MARK: - Constants +// +private extension HTMLStorage { + + /// Regular Expressions used to match HTML + /// + struct RegExes { + static let comments = try! NSRegularExpression(pattern: "", options: .caseInsensitive) + static let html = try! NSRegularExpression(pattern: "<[^>]+>", options: .caseInsensitive) + static let quotes = try! NSRegularExpression(pattern: "\".*?\"", options: .caseInsensitive) + } + + + /// Default Styles + /// + struct Styles { + static let defaultCommentColor = UIColor.lightGray + static let defaultTagColor = UIColor(colorLiteralRed: 0x00/255.0, green: 0x75/255.0, blue: 0xB6/255.0, alpha: 0xFF/255.0) + static let defaultQuotedColor = UIColor(colorLiteralRed: 0x6E/255.0, green: 0x96/255.0, blue: 0xB1/255.0, alpha: 0xFF/255.0) + } +} diff --git a/Aztec/Classes/TextKit/ImageAttachment.swift b/Aztec/Classes/TextKit/ImageAttachment.swift new file mode 100644 index 000000000..e3dd5a2f1 --- /dev/null +++ b/Aztec/Classes/TextKit/ImageAttachment.swift @@ -0,0 +1,204 @@ +import Foundation +import UIKit + +/// Custom text attachment. +/// +open class ImageAttachment: MediaAttachment { + + /// Attachment Alignment + /// + open var alignment: Alignment = .center { + willSet { + if newValue != alignment { + glyphImage = nil + } + } + } + + /// Attachment Size + /// + open var size: Size = .full { + willSet { + if newValue != size { + glyphImage = nil + } + } + } + + /// Creates a new attachment + /// + /// - Parameters: + /// - identifier: An unique identifier for the attachment + /// - url: the url that represents the image + /// + required public init(identifier: String, url: URL? = nil) { + super.init(identifier: identifier, url: url) + } + + /// Required Initializer + /// + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + if aDecoder.containsValue(forKey: EncodeKeys.alignment.rawValue) { + let alignmentRaw = aDecoder.decodeInteger(forKey: EncodeKeys.alignment.rawValue) + if let alignment = Alignment(rawValue:alignmentRaw) { + self.alignment = alignment + } + } + if aDecoder.containsValue(forKey: EncodeKeys.size.rawValue) { + let sizeRaw = aDecoder.decodeInteger(forKey: EncodeKeys.size.rawValue) + if let size = Size(rawValue:sizeRaw) { + self.size = size + } + } + } + + /// Required Initializer + /// + required public init(data contentData: Data?, ofType uti: String?) { + super.init(data: contentData, ofType: uti) + } + + + // MARK: - NSCoder Support + + override open func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + aCoder.encode(alignment.rawValue, forKey: EncodeKeys.alignment.rawValue) + aCoder.encode(size.rawValue, forKey: EncodeKeys.size.rawValue) + } + + fileprivate enum EncodeKeys: String { + case alignment + case size + } + + // MARK: - Origin calculation + + override func xPosition(forContainerWidth containerWidth: CGFloat) -> CGFloat { + let imageWidth = onScreenWidth(containerWidth) + + switch (alignment) { + case .center: + return CGFloat(floor((containerWidth - imageWidth) / 2)) + case .right: + return CGFloat(floor(containerWidth - imageWidth)) + default: + return 0 + } + } + + override func onScreenHeight(_ containerWidth: CGFloat) -> CGFloat { + if let image = image { + let targetWidth = onScreenWidth(containerWidth) + let scale = targetWidth / image.size.width + + return floor(image.size.height * scale) + (imageMargin * 2) + } else { + return 0 + } + } + + override func onScreenWidth(_ containerWidth: CGFloat) -> CGFloat { + if let image = image { + switch (size) { + case .full: + return floor(min(image.size.width, containerWidth)) + default: + return floor(min(min(image.size.width,size.width), containerWidth)) + } + } else { + return 0 + } + } +} + + + +/// Nested Types +/// +extension ImageAttachment +{ + /// Alignment + /// + public enum Alignment: Int { + case none + case left + case center + case right + + func htmlString() -> String { + switch self { + case .center: + return "aligncenter" + case .left: + return "alignleft" + case .right: + return "alignright" + case .none: + return "alignnone" + } + } + + static let mappedValues:[String:Alignment] = [ + Alignment.none.htmlString():.none, + Alignment.left.htmlString():.left, + Alignment.center.htmlString():.center, + Alignment.right.htmlString():.right + ] + + static func fromHTML(string value:String) -> Alignment? { + return mappedValues[value] + } + } + + /// Size Onscreen! + /// + public enum Size: Int { + case thumbnail + case medium + case large + case full + + func htmlString() -> String { + switch self { + case .thumbnail: + return "size-thumbnail" + case .medium: + return "size-medium" + case .large: + return "size-large" + case .full: + return "size-full" + } + } + + static let mappedValues:[String:Size] = [ + Size.thumbnail.htmlString():.thumbnail, + Size.medium.htmlString():.medium, + Size.large.htmlString():.large, + Size.full.htmlString():.full + ] + + static func fromHTML(string value:String) -> Size? { + return mappedValues[value] + } + + var width: CGFloat { + switch self { + case .thumbnail: return Settings.thumbnail + case .medium: return Settings.medium + case .large: return Settings.large + case .full: return Settings.maximum + } + } + + fileprivate struct Settings { + static let thumbnail = CGFloat(135) + static let medium = CGFloat(270) + static let large = CGFloat(360) + static let maximum = CGFloat.greatestFiniteMagnitude + } + } +} diff --git a/Aztec/Classes/TextKit/LayoutManager.swift b/Aztec/Classes/TextKit/LayoutManager.swift index f371e9264..14c6fa345 100644 --- a/Aztec/Classes/TextKit/LayoutManager.swift +++ b/Aztec/Classes/TextKit/LayoutManager.swift @@ -9,19 +9,24 @@ class LayoutManager: NSLayoutManager { /// Blockquote's Left Border Color /// - var blockquoteBorderColor: UIColor = UIColor(red: 0.52, green: 0.65, blue: 0.73, alpha: 1.0) + var blockquoteBorderColor = UIColor(red: 0.52, green: 0.65, blue: 0.73, alpha: 1.0) /// Blockquote's Background Color /// var blockquoteBackgroundColor = UIColor(red: 0.91, green: 0.94, blue: 0.95, alpha: 1.0) + /// HTML Pre Background Color + /// + var preBackgroundColor = UIColor(red: 243.0/255.0, green: 246.0/255.0, blue: 248.0/255.0, alpha: 1.0) + /// Draws the background, associated to a given Text Range /// override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) { super.drawBackground(forGlyphRange: glyphsToShow, at: origin) drawBlockquotes(forGlyphRange: glyphsToShow, at: origin) + drawHTMLPre(forGlyphRange: glyphsToShow, at: origin) drawLists(forGlyphRange: glyphsToShow, at: origin) } } @@ -45,7 +50,7 @@ private extension LayoutManager { let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil) //draw blockquotes textStorage.enumerateAttribute(NSParagraphStyleAttributeName, in: characterRange, options: []){ (object, range, stop) in - guard let paragraphStyle = object as? ParagraphStyle, paragraphStyle.blockquote != nil else { + guard let paragraphStyle = object as? ParagraphStyle, !paragraphStyle.blockquotes.isEmpty else { return } @@ -53,12 +58,7 @@ private extension LayoutManager { enumerateLineFragments(forGlyphRange: blockquoteGlyphRange) { (rect, usedRect, textContainer, glyphRange, stop) in let lineRect = rect.offsetBy(dx: origin.x, dy: origin.y) - self.drawBlockquote(in: lineRect, with: context) - } - - if range.endLocation == textStorage.rangeOfEntireString.endLocation { - let extraLineRect = extraLineFragmentRect.offsetBy(dx: origin.x, dy: origin.y) - drawBlockquote(in: extraLineRect, with: context) + self.drawBlockquote(in: lineRect.integral, with: context) } } @@ -76,6 +76,45 @@ private extension LayoutManager { } } +// MARK: - PreFormatted Helpers +// +private extension LayoutManager { + + /// Draws a HTML Pre associated to a Range + Graphics Origin. + /// + func drawHTMLPre(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) { + guard let textStorage = textStorage else { + return + } + + guard let context = UIGraphicsGetCurrentContext() else { + preconditionFailure("When drawBackgroundForGlyphRange is called, the graphics context is supposed to be set by UIKit") + } + + let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil) + //draw html pre paragraphs + textStorage.enumerateAttribute(NSParagraphStyleAttributeName, in: characterRange, options: []){ (object, range, stop) in + guard let paragraphStyle = object as? ParagraphStyle, paragraphStyle.htmlPre != nil else { + return + } + + let preGlyphRange = glyphRange(forCharacterRange: range, actualCharacterRange: nil) + + enumerateLineFragments(forGlyphRange: preGlyphRange) { (rect, usedRect, textContainer, glyphRange, stop) in + let lineRect = rect.offsetBy(dx: origin.x, dy: origin.y) + self.drawHTMLPre(in: lineRect.integral, with: context) + } + } + + } + + /// Draws a single HTML Pre Line Fragment, in the specified Rectangle, using a given Graphics Context. + /// + private func drawHTMLPre(in rect: CGRect, with context: CGContext) { + preBackgroundColor.setFill() + context.fill(rect) + } +} // MARK: - Lists Helpers // @@ -89,36 +128,30 @@ private extension LayoutManager { } let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil) - textStorage.enumerateAttribute(NSParagraphStyleAttributeName, in: characterRange, options: []) { (object, range, stop) in - guard let paragraphStyle = object as? ParagraphStyle, let list = paragraphStyle.textList else { - return - } - let listGlyphRange = glyphRange(forCharacterRange:range, actualCharacterRange: nil) + textStorage.enumerateParagraphRanges(spanning: characterRange) { (range, enclosingRange) in - // Draw Paragraph Markers - enumerateLineFragments(forGlyphRange: listGlyphRange) { (rect, usedRect, textContainer, glyphRange, stop) in - let location = self.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil).location - guard textStorage.isStartOfNewLine(atLocation: location) else { + guard textStorage.string.isStartOfNewLine(atUTF16Offset: enclosingRange.location), + let paragraphStyle = textStorage.attribute(NSParagraphStyleAttributeName, at: range.location, effectiveRange: nil) as? ParagraphStyle, + let list = paragraphStyle.lists.last else { return - } - - let markerNumber = textStorage.itemNumber(in: list, at: location) - let lineRect = rect.offsetBy(dx: origin.x, dy: origin.y) - - self.drawItem(number: markerNumber, in: lineRect, from: list, using: paragraphStyle, at: location) } - // Draw the Last Line's Item - guard range.endLocation == textStorage.rangeOfEntireString.endLocation, !extraLineFragmentRect.isEmpty else { - return - } + let glyphRange = self.glyphRange(forCharacterRange: enclosingRange, actualCharacterRange: nil) + + // Since only the first line in a paragraph can have a bullet, we only need the first line fragment. + // + let lineFragmentRect = self.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) + let lineFragmentRectWithOffset = lineFragmentRect.offsetBy(dx: origin.x, dy: origin.y) - let location = range.endLocation - 1 - let lineRect = extraLineFragmentRect.offsetBy(dx: origin.x, dy: origin.y) - let markerNumber = textStorage.itemNumber(in: list, at: location) + 1 + let markerNumber = textStorage.itemNumber(in: list, at: enclosingRange.location) - drawItem(number: markerNumber, in: lineRect, from: list, using: paragraphStyle, at: location) + self.drawItem( + number: markerNumber, + in: lineFragmentRectWithOffset, + from: list, + using: paragraphStyle, + at: enclosingRange.location) } } @@ -140,10 +173,16 @@ private extension LayoutManager { let paragraphAttributes = textStorage.attributes(at: location, effectiveRange: nil) let markerAttributes = markerAttributesBasedOnParagraph(attributes: paragraphAttributes) - let markerRect = rect.offsetBy(dx: style.headIndent - Metrics.defaultIndentation, dy: style.paragraphSpacingBefore) let markerPlain = list.style.markerText(forItemNumber: number) let markerText = NSAttributedString(string: markerPlain, attributes: markerAttributes) + var yOffset = -style.paragraphSpacing + + if let font = markerAttributes[NSFontAttributeName] as? UIFont { + yOffset += (rect.height - font.lineHeight) + } + + let markerRect = rect.offsetBy(dx: 0, dy: yOffset) markerText.draw(in: markerRect) } @@ -152,18 +191,50 @@ private extension LayoutManager { /// private func markerAttributesBasedOnParagraph(attributes: [String: Any]) -> [String: Any] { var resultAttributes = attributes - resultAttributes[NSParagraphStyleAttributeName] = ParagraphStyle.default + var indent: CGFloat = 0 + if let style = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { + indent = style.headIndent + } + + resultAttributes[NSParagraphStyleAttributeName] = markerParagraphStyle(indent: indent) resultAttributes.removeValue(forKey: NSUnderlineStyleAttributeName) resultAttributes.removeValue(forKey: NSStrikethroughStyleAttributeName) resultAttributes.removeValue(forKey: NSLinkAttributeName) + if let font = resultAttributes[NSFontAttributeName] as? UIFont { - var traits = font.fontDescriptor.symbolicTraits - traits.remove(.traitBold) - traits.remove(.traitItalic) - let descriptor = font.fontDescriptor.withSymbolicTraits(traits) - let newFont = UIFont(descriptor: descriptor!, size: font.pointSize) - resultAttributes[NSFontAttributeName] = newFont + resultAttributes[NSFontAttributeName] = fixFontForMarkerAttributes(font: font) } + return resultAttributes } + + + /// Returns the Marker Paratraph Attributes + /// + private func markerParagraphStyle(indent: CGFloat) -> NSParagraphStyle { + let tabStop = NSTextTab(textAlignment: .right, location: indent, options: [:]) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.tabStops = [tabStop] + + return paragraphStyle + } + + + /// Fixes a UIFont Instance for List Marker Usage: + /// + /// - Emoji Font is replaced by the System's font, of matching size + /// - Bold and Italic styling is neutralized + /// + private func fixFontForMarkerAttributes(font: UIFont) -> UIFont { + guard !font.isAppleEmojiFont else { + return UIFont.systemFont(ofSize: font.pointSize) + } + + var traits = font.fontDescriptor.symbolicTraits + traits.remove(.traitBold) + traits.remove(.traitItalic) + + let descriptor = font.fontDescriptor.withSymbolicTraits(traits) + return UIFont(descriptor: descriptor!, size: font.pointSize) + } } diff --git a/Aztec/Classes/TextKit/LineAttachment.swift b/Aztec/Classes/TextKit/LineAttachment.swift index 16b9c6308..9fa153137 100644 --- a/Aztec/Classes/TextKit/LineAttachment.swift +++ b/Aztec/Classes/TextKit/LineAttachment.swift @@ -1,15 +1,16 @@ import Foundation import UIKit + /// Custom horizontal line drawing attachment. /// -open class LineAttachment: NSTextAttachment -{ +open class LineAttachment: NSTextAttachment { + fileprivate var glyphImage: UIImage? /// The color to use when drawing progress indicators /// - open var color: UIColor = UIColor.gray + open var color = UIColor.gray // MARK: - NSTextAttachmentContainer diff --git a/Aztec/Classes/TextKit/TextAttachment.swift b/Aztec/Classes/TextKit/MediaAttachment.swift similarity index 54% rename from Aztec/Classes/TextKit/TextAttachment.swift rename to Aztec/Classes/TextKit/MediaAttachment.swift index a59354e37..8c8ec06a2 100644 --- a/Aztec/Classes/TextKit/TextAttachment.swift +++ b/Aztec/Classes/TextKit/MediaAttachment.swift @@ -1,17 +1,19 @@ import Foundation import UIKit -protocol TextAttachmentDelegate: class { - func textAttachment( - _ textAttachment: TextAttachment, +protocol MediaAttachmentDelegate: class { + func mediaAttachment( + _ mediaAttachment: MediaAttachment, imageForURL url: URL, onSuccess success: @escaping (UIImage) -> (), onFailure failure: @escaping () -> ()) -> UIImage + + func mediaAttachmentPlaceholderImageFor(attachment: MediaAttachment) -> UIImage } /// Custom text attachment. /// -open class TextAttachment: NSTextAttachment +open class MediaAttachment: NSTextAttachment { public struct Appearance { public var overlayColor = UIColor(white: 0.6, alpha: 0.6) @@ -46,26 +48,6 @@ open class TextAttachment: NSTextAttachment open var url: URL? fileprivate var lastRequestedURL: URL? - /// Attachment Alignment - /// - open var alignment: Alignment = .center { - willSet { - if newValue != alignment { - glyphImage = nil - } - } - } - - /// Attachment Size - /// - open var size: Size = .full { - willSet { - if newValue != size { - glyphImage = nil - } - } - } - /// A progress value that indicates the progress of an attachment. It can be set between values 0 and 1 /// open var progress: Double? = nil { @@ -79,22 +61,22 @@ open class TextAttachment: NSTextAttachment /// The color to use when drawing the background overlay for messages, icons, and progress /// - open var overlayColor: UIColor = TextAttachment.appearance.overlayColor + open var overlayColor: UIColor = MediaAttachment.appearance.overlayColor /// The height of the progress bar for progress indicators - open var progressHeight: CGFloat = TextAttachment.appearance.progressHeight + open var progressHeight: CGFloat = MediaAttachment.appearance.progressHeight /// The color to use when drawing the backkground of the progress indicators /// - open var progressBackgroundColor: UIColor = TextAttachment.appearance.progressBackgroundColor + open var progressBackgroundColor: UIColor = MediaAttachment.appearance.progressBackgroundColor /// The color to use when drawing progress indicators /// - open var progressColor: UIColor = TextAttachment.appearance.progressColor + open var progressColor: UIColor = MediaAttachment.appearance.progressColor /// The margin apply to the images being displayed. This is to avoid that two images in a row get glued together. /// - open var imageMargin: CGFloat = TextAttachment.appearance.imageMargin + open var imageMargin: CGFloat = MediaAttachment.appearance.imageMargin /// A message to display overlaid on top of the image /// @@ -116,7 +98,6 @@ open class TextAttachment: NSTextAttachment } } - /// Clears all overlay information that is applied to the attachment /// open func clearAllOverlays() { @@ -125,17 +106,17 @@ open class TextAttachment: NSTextAttachment overlayImage = nil } - fileprivate var glyphImage: UIImage? + internal var glyphImage: UIImage? - weak var delegate: TextAttachmentDelegate? + weak var delegate: MediaAttachmentDelegate? var isFetchingImage: Bool = false /// Creates a new attachment /// - /// - parameter identifier: An unique identifier for the attachment - /// - /// - returns: self, initilized with the identifier a with kind = .MissingImage + /// - Parameters: + /// - identifier: An unique identifier for the attachment + /// - url: the url that represents the image /// required public init(identifier: String, url: URL? = nil) { self.identifier = identifier @@ -156,31 +137,19 @@ open class TextAttachment: NSTextAttachment if aDecoder.containsValue(forKey: EncodeKeys.url.rawValue) { url = aDecoder.decodeObject(forKey: EncodeKeys.url.rawValue) as? URL } - if aDecoder.containsValue(forKey: EncodeKeys.alignment.rawValue) { - let alignmentRaw = aDecoder.decodeInteger(forKey: EncodeKeys.alignment.rawValue) - if let alignment = Alignment(rawValue:alignmentRaw) { - self.alignment = alignment - } - } - if aDecoder.containsValue(forKey: EncodeKeys.size.rawValue) { - let sizeRaw = aDecoder.decodeInteger(forKey: EncodeKeys.size.rawValue) - if let size = Size(rawValue:sizeRaw) { - self.size = size - } - } } - override init(data contentData: Data?, ofType uti: String?) { + override required public init(data contentData: Data?, ofType uti: String?) { identifier = "" url = nil super.init(data: contentData, ofType: uti) } fileprivate func setupDefaultAppearance() { - progressHeight = TextAttachment.appearance.progressHeight - progressBackgroundColor = TextAttachment.appearance.progressBackgroundColor - progressColor = TextAttachment.appearance.progressColor - overlayColor = TextAttachment.appearance.overlayColor + progressHeight = MediaAttachment.appearance.progressHeight + progressBackgroundColor = MediaAttachment.appearance.progressBackgroundColor + progressColor = MediaAttachment.appearance.progressColor + overlayColor = MediaAttachment.appearance.overlayColor } override open func encode(with aCoder: NSCoder) { @@ -189,32 +158,19 @@ open class TextAttachment: NSTextAttachment if let url = self.url { aCoder.encode(url, forKey: EncodeKeys.url.rawValue) } - aCoder.encode(alignment.rawValue, forKey: EncodeKeys.alignment.rawValue) - aCoder.encode(size.rawValue, forKey: EncodeKeys.size.rawValue) } fileprivate enum EncodeKeys: String { case identifier case url - case alignment - case size } - // MARK: - Origin calculation - fileprivate func xPosition(forContainerWidth containerWidth: CGFloat) -> CGFloat { - let imageWidth = onScreenWidth(containerWidth) - - switch (alignment) { - case .center: - return CGFloat(floor((containerWidth - imageWidth) / 2)) - case .right: - return CGFloat(floor(containerWidth - imageWidth)) - default: + // MARK: - Position and size calculation + func xPosition(forContainerWidth containerWidth: CGFloat) -> CGFloat { return 0 - } } - fileprivate func onScreenHeight(_ containerWidth: CGFloat) -> CGFloat { + func onScreenHeight(_ containerWidth: CGFloat) -> CGFloat { if let image = image { let targetWidth = onScreenWidth(containerWidth) let scale = targetWidth / image.size.width @@ -225,14 +181,9 @@ open class TextAttachment: NSTextAttachment } } - fileprivate func onScreenWidth(_ containerWidth: CGFloat) -> CGFloat { + func onScreenWidth(_ containerWidth: CGFloat) -> CGFloat { if let image = image { - switch (size) { - case .full: - return floor(min(image.size.width, containerWidth)) - default: - return floor(min(min(image.size.width,size.width), containerWidth)) - } + return floor(min(image.size.width, containerWidth)) } else { return 0 } @@ -257,45 +208,25 @@ open class TextAttachment: NSTextAttachment return glyphImage } - fileprivate func glyph(basedOnImage image:UIImage, forBounds bounds: CGRect) -> UIImage? { - + func mediaBounds(forBounds bounds: CGRect) -> CGRect { let containerWidth = bounds.size.width let origin = CGPoint(x: xPosition(forContainerWidth: bounds.size.width), y: imageMargin) let size = CGSize(width: onScreenWidth(containerWidth), height: onScreenHeight(containerWidth) - imageMargin) + return CGRect(origin: origin, size: size) + } + + fileprivate func glyph(basedOnImage image:UIImage, forBounds bounds: CGRect) -> UIImage? { UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) - image.draw(in: CGRect(origin: origin, size: size)) - - if message != nil || progress != nil { - let box = UIBezierPath() - box.move(to: CGPoint(x:origin.x, y:origin.y)) - box.addLine(to: CGPoint(x: origin.x + size.width, y: origin.y)) - box.addLine(to: CGPoint(x: origin.x + size.width, y: origin.y + size.height)) - box.addLine(to: CGPoint(x: origin.x, y: origin.y + size.height)) - box.addLine(to: CGPoint(x: origin.x, y: origin.y)) - box.lineWidth = 2.0 - overlayColor.setFill() - box.fill() - } + let mediaBounds = self.mediaBounds(forBounds: bounds) + let origin = mediaBounds.origin + let size = mediaBounds.size - if let progress = progress { - let lineY = origin.y + (progressHeight / 2.0) - - let backgroundPath = UIBezierPath() - backgroundPath.lineWidth = progressHeight - progressBackgroundColor.setStroke() - backgroundPath.move(to: CGPoint(x:origin.x, y: lineY)) - backgroundPath.addLine(to: CGPoint(x: origin.x + size.width, y: lineY )) - backgroundPath.stroke() - - let path = UIBezierPath() - path.lineWidth = progressHeight - progressColor.setStroke() - path.move(to: CGPoint(x:origin.x, y: lineY)) - path.addLine(to: CGPoint(x: origin.x + (size.width * CGFloat(max(0,min(progress,1)))), y: lineY )) - path.stroke() - } + image.draw(in: mediaBounds) + + drawOverlayBackground(at: origin, size: size) + drawProgress(at: origin, size: size) var imagePadding: CGFloat = 0 if let overlayImage = overlayImage { @@ -323,6 +254,42 @@ open class TextAttachment: NSTextAttachment return result; } + fileprivate func drawOverlayBackground(at origin: CGPoint, size:CGSize) { + guard message != nil || progress != nil else { + return + } + let box = UIBezierPath() + box.move(to: CGPoint(x:origin.x, y:origin.y)) + box.addLine(to: CGPoint(x: origin.x + size.width, y: origin.y)) + box.addLine(to: CGPoint(x: origin.x + size.width, y: origin.y + size.height)) + box.addLine(to: CGPoint(x: origin.x, y: origin.y + size.height)) + box.addLine(to: CGPoint(x: origin.x, y: origin.y)) + box.lineWidth = 2.0 + overlayColor.setFill() + box.fill() + } + + fileprivate func drawProgress(at origin: CGPoint, size:CGSize) { + guard let progress = progress else { + return + } + let lineY = origin.y + (progressHeight / 2.0) + + let backgroundPath = UIBezierPath() + backgroundPath.lineWidth = progressHeight + progressBackgroundColor.setStroke() + backgroundPath.move(to: CGPoint(x:origin.x, y: lineY)) + backgroundPath.addLine(to: CGPoint(x: origin.x + size.width, y: lineY )) + backgroundPath.stroke() + + let path = UIBezierPath() + path.lineWidth = progressHeight + progressColor.setStroke() + path.move(to: CGPoint(x:origin.x, y: lineY)) + path.addLine(to: CGPoint(x: origin.x + (size.width * CGFloat(max(0,min(progress,1)))), y: lineY )) + path.stroke() + } + /// Returns the "Onscreen Character Size" of the attachment range. When we're in Alignment.None, /// the attachment will be 'Inline', and thus, we'll return the actual Associated View Size. /// Otherwise, we'll always take the whole container's width. @@ -335,10 +302,16 @@ open class TextAttachment: NSTextAttachment return CGRect.zero } - let padding = textContainer?.lineFragmentPadding ?? 0 - let width = lineFrag.width - padding * 2 + var padding = (textContainer?.lineFragmentPadding ?? 0) + if let storage = textContainer?.layoutManager?.textStorage, + let paragraphStyle = storage.attribute(NSParagraphStyleAttributeName, at: charIndex, effectiveRange: nil) as? NSParagraphStyle { + padding += paragraphStyle.firstLineHeadIndent + paragraphStyle.tailIndent + } + let width = floor(lineFrag.width - (padding * 2)) - return CGRect(origin: CGPoint.zero, size: CGSize(width: width, height: onScreenHeight(width))) + let size = CGSize(width: width, height: onScreenHeight(width)) + + return CGRect(origin: CGPoint.zero, size: size) } func updateImage(inTextContainer textContainer: NSTextContainer? = nil) { @@ -349,12 +322,15 @@ open class TextAttachment: NSTextAttachment } guard let url = url, !isFetchingImage && url != lastRequestedURL else { + if self.url == nil { + self.image = delegate.mediaAttachmentPlaceholderImageFor(attachment: self) + } return } isFetchingImage = true - let image = delegate.textAttachment(self, imageForURL: url, onSuccess: { [weak self] image in + let image = delegate.mediaAttachment(self, imageForURL: url, onSuccess: { [weak self] image in guard let strongSelf = self else { return } @@ -369,104 +345,15 @@ open class TextAttachment: NSTextAttachment } strongSelf.isFetchingImage = false + strongSelf.lastRequestedURL = nil + strongSelf.image = nil strongSelf.invalidateLayout(inTextContainer: textContainer) }) - if self.image == nil { - self.image = image - } + self.image = image } fileprivate func invalidateLayout(inTextContainer textContainer: NSTextContainer?) { - textContainer?.layoutManager?.invalidateLayoutForAttachment(self) - } -} - - - -/// Nested Types -/// -extension TextAttachment -{ - /// Alignment - /// - public enum Alignment: Int { - case none - case left - case center - case right - - func htmlString() -> String { - switch self { - case .center: - return "aligncenter" - case .left: - return "alignleft" - case .right: - return "alignright" - case .none: - return "alignnone" - } - } - - static let mappedValues:[String:Alignment] = [ - Alignment.none.htmlString():.none, - Alignment.left.htmlString():.left, - Alignment.center.htmlString():.center, - Alignment.right.htmlString():.right - ] - - static func fromHTML(string value:String) -> Alignment? { - return mappedValues[value] - } - } - - /// Size Onscreen! - /// - public enum Size: Int { - case thumbnail - case medium - case large - case full - - func htmlString() -> String { - switch self { - case .thumbnail: - return "size-thumbnail" - case .medium: - return "size-medium" - case .large: - return "size-large" - case .full: - return "size-full" - } - } - - static let mappedValues:[String:Size] = [ - Size.thumbnail.htmlString():.thumbnail, - Size.medium.htmlString():.medium, - Size.large.htmlString():.large, - Size.full.htmlString():.full - ] - - static func fromHTML(string value:String) -> Size? { - return mappedValues[value] - } - - var width: CGFloat { - switch self { - case .thumbnail: return Settings.thumbnail - case .medium: return Settings.medium - case .large: return Settings.large - case .full: return Settings.maximum - } - } - - fileprivate struct Settings { - static let thumbnail = CGFloat(135) - static let medium = CGFloat(270) - static let large = CGFloat(360) - static let maximum = CGFloat.greatestFiniteMagnitude - } + textContainer?.layoutManager?.invalidateLayout(for: self) } } diff --git a/Aztec/Classes/TextKit/ParagraphProperty/Blockquote.swift b/Aztec/Classes/TextKit/ParagraphProperty/Blockquote.swift new file mode 100644 index 000000000..2860bda64 --- /dev/null +++ b/Aztec/Classes/TextKit/ParagraphProperty/Blockquote.swift @@ -0,0 +1,21 @@ +import Foundation + +class Blockquote: ParagraphProperty { + + public override func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + } + + override public init(with representation: HTMLElementRepresentation? = nil) { + super.init(with: representation) + } + + required public init?(coder aDecoder: NSCoder){ + super.init(coder: aDecoder) + } + + static func ==(lhs: Blockquote, rhs: Blockquote) -> Bool { + return true + } +} + diff --git a/Aztec/Classes/TextKit/ParagraphProperty/HTMLParagraph.swift b/Aztec/Classes/TextKit/ParagraphProperty/HTMLParagraph.swift new file mode 100644 index 000000000..100be7601 --- /dev/null +++ b/Aztec/Classes/TextKit/ParagraphProperty/HTMLParagraph.swift @@ -0,0 +1,20 @@ +import Foundation + +class HTMLParagraph: ParagraphProperty { + + override public func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + } + + override public init(with representation: HTMLElementRepresentation? = nil) { + super.init(with: representation) + } + + required public init?(coder aDecoder: NSCoder){ + super.init(coder: aDecoder) + } + + static func ==(lhs: HTMLParagraph, rhs: HTMLParagraph) -> Bool { + return lhs === rhs + } +} diff --git a/Aztec/Classes/TextKit/ParagraphProperty/HTMLPre.swift b/Aztec/Classes/TextKit/ParagraphProperty/HTMLPre.swift new file mode 100644 index 000000000..007ec0bf2 --- /dev/null +++ b/Aztec/Classes/TextKit/ParagraphProperty/HTMLPre.swift @@ -0,0 +1,20 @@ +import Foundation + +class HTMLPre: ParagraphProperty { + + override public func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + } + + override public init(with representation: HTMLElementRepresentation? = nil) { + super.init(with: representation) + } + + required public init?(coder aDecoder: NSCoder){ + super.init(coder: aDecoder) + } + + static func ==(lhs: HTMLPre, rhs: HTMLPre) -> Bool { + return lhs === rhs + } +} diff --git a/Aztec/Classes/TextKit/ParagraphProperty/Header.swift b/Aztec/Classes/TextKit/ParagraphProperty/Header.swift new file mode 100644 index 000000000..cbc4aad1a --- /dev/null +++ b/Aztec/Classes/TextKit/ParagraphProperty/Header.swift @@ -0,0 +1,64 @@ +import Foundation +import UIKit + + +// MARK: - Header property for paragraphs +// +open class Header: ParagraphProperty +{ + // MARK: - Nested Types + + /// Available Heading Types + /// + public enum HeaderType: Int { + case none = 0 + case h1 = 1 + case h2 = 2 + case h3 = 3 + case h4 = 4 + case h5 = 5 + case h6 = 6 + + public var fontSize: CGFloat { + switch self { + case .none: return 14 + case .h1: return 36 + case .h2: return 24 + case .h3: return 21 + case .h4: return 16 + case .h5: return 14 + case .h6: return 11 + } + } + } + + // MARK: - Properties + + /// Kind of Header: Header 1, Header 2, etc.. + /// + let level: HeaderType + + init(level: HeaderType, with representation: HTMLElementRepresentation? = nil) { + self.level = level + super.init(with: representation) + } + + public required init?(coder aDecoder: NSCoder) { + if aDecoder.containsValue(forKey: String(describing: HeaderType.self)), + let decodedStyle = HeaderType(rawValue:aDecoder.decodeInteger(forKey: String(describing: HeaderType.self))) { + level = decodedStyle + } else { + level = .none + } + super.init(coder: aDecoder) + } + + public override func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + aCoder.encode(level.rawValue, forKey: String(describing: HeaderType.self)) + } + + static func ==(lhs: Header, rhs: Header) -> Bool { + return lhs.level == rhs.level + } +} diff --git a/Aztec/Classes/TextKit/ParagraphProperty/ParagraphProperty.swift b/Aztec/Classes/TextKit/ParagraphProperty/ParagraphProperty.swift new file mode 100644 index 000000000..7bf105439 --- /dev/null +++ b/Aztec/Classes/TextKit/ParagraphProperty/ParagraphProperty.swift @@ -0,0 +1,25 @@ +import Foundation + +open class ParagraphProperty: NSObject, NSCoding { + + var representation: HTMLElementRepresentation? + + public override init() { + self.representation = nil + } + + init(with representation: HTMLElementRepresentation? = nil) { + self.representation = representation + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + } + + public func encode(with aCoder: NSCoder) { + } + + static func ==(lhs: ParagraphProperty, rhs: ParagraphProperty) -> Bool { + return lhs == rhs + } +} diff --git a/Aztec/Classes/TextKit/ParagraphProperty/TextList.swift b/Aztec/Classes/TextKit/ParagraphProperty/TextList.swift new file mode 100644 index 000000000..111ceeb39 --- /dev/null +++ b/Aztec/Classes/TextKit/ParagraphProperty/TextList.swift @@ -0,0 +1,54 @@ +import Foundation +import UIKit + + +// MARK: - Text List +// +open class TextList: ParagraphProperty +{ + // MARK: - Nested Types + + /// List Styles + /// + public enum Style: Int { + case ordered + case unordered + + func markerText(forItemNumber number: Int) -> String { + switch self { + case .ordered: return "\t\(number).\t" + case .unordered: return "\t\u{2022}\t\t" + } + } + } + + // MARK: - Properties + + /// Kind of List: Ordered / Unordered + /// + let style: Style + + init(style: Style, with representation: HTMLElementRepresentation? = nil) { + self.style = style + super.init(with: representation) + } + + public required init?(coder aDecoder: NSCoder) { + if aDecoder.containsValue(forKey: String(describing: Style.self)), + let decodedStyle = Style(rawValue:aDecoder.decodeInteger(forKey: String(describing: Style.self))) { + style = decodedStyle + } else { + style = .ordered + } + super.init(coder: aDecoder) + } + + public override func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + aCoder.encode(style.rawValue, forKey: String(describing: Style.self)) + } + + public static func ==(lhs: TextList, rhs: TextList) -> Bool { + return lhs.style == rhs.style + } +} diff --git a/Aztec/Classes/TextKit/ParagraphStyle.swift b/Aztec/Classes/TextKit/ParagraphStyle.swift index e0f94fe02..ee1b24ad8 100644 --- a/Aztec/Classes/TextKit/ParagraphStyle.swift +++ b/Aztec/Classes/TextKit/ParagraphStyle.swift @@ -1,29 +1,90 @@ import Foundation import UIKit -open class ParagraphStyle: NSMutableParagraphStyle { +open class ParagraphStyle: NSMutableParagraphStyle, CustomReflectable { + + // MARK: - CustomReflectable + + public var customMirror: Mirror { + get { + return Mirror(self, children: ["blockquotes": blockquotes as Any, "headerLevel": headerLevel, "htmlParagraph": htmlParagraph as Any, "textList": lists as Any]) + } + } private enum EncodingKeys: String { case headerLevel } - var textList: TextList? - var blockquote: Blockquote? - var headerLevel: Int = 0 + var properties = [ParagraphProperty]() + + var blockquotes: [Blockquote] { + return properties.flatMap { (property) -> Blockquote? in + if let blockquote = property as? Blockquote { + return blockquote + } else { + return nil + } + } + } + + var htmlParagraph: [HTMLParagraph] { + return properties.flatMap { (property) -> HTMLParagraph? in + if let paragraph = property as? HTMLParagraph { + return paragraph + } else { + return nil + } + } + } + + var lists : [TextList] { + return properties.flatMap { (property) -> TextList? in + if let textList = property as? TextList { + return textList + } else { + return nil + } + } + } + + var headers: [Header] { + return properties.flatMap { (property) -> Header? in + if let header = property as? Header { + return header + } else { + return nil + } + } + } + + var headerLevel: Int { + let availableHeaders = headers + if availableHeaders.isEmpty { + return 0 + } else { + return availableHeaders.last!.level.rawValue + } + } + + var htmlPre: HTMLPre? { + let htmlPres = properties.flatMap { (property) -> HTMLPre? in + if let htmlPre = property as? HTMLPre { + return htmlPre + } else { + return nil + } + } + return htmlPres.first + } override init() { super.init() } public required init?(coder aDecoder: NSCoder) { - if aDecoder.containsValue(forKey: String(describing: TextList.self)) { - let styleRaw = aDecoder.decodeInteger(forKey: String(describing: TextList.self)) - if let style = TextList.Style(rawValue:styleRaw) { - textList = TextList(style: style) - } - } - if aDecoder.containsValue(forKey: String(describing: Blockquote.self)) { - blockquote = aDecoder.decodeObject(forKey: String(describing: Blockquote.self)) as? Blockquote + + if let encodedProperties = aDecoder.decodeObject(forKey:String(describing: ParagraphProperty.self)) as? [ParagraphProperty] { + properties = encodedProperties } aDecoder.decodeInteger(forKey: EncodingKeys.headerLevel.rawValue) @@ -33,13 +94,8 @@ open class ParagraphStyle: NSMutableParagraphStyle { override open func encode(with aCoder: NSCoder) { super.encode(with: aCoder) - if let textListSet = textList { - aCoder.encode(textListSet.style.rawValue, forKey: String(describing: TextList.self)) - } - if let blockquote = self.blockquote { - aCoder.encode(blockquote, forKey: String(describing: Blockquote.self)) - } + aCoder.encode(properties, forKey: String(describing: ParagraphProperty.self)) aCoder.encode(headerLevel, forKey: EncodingKeys.headerLevel.rawValue) } @@ -47,12 +103,92 @@ open class ParagraphStyle: NSMutableParagraphStyle { override open func setParagraphStyle(_ obj: NSParagraphStyle) { super.setParagraphStyle(obj) if let paragrahStyle = obj as? ParagraphStyle { - textList = paragrahStyle.textList - blockquote = paragrahStyle.blockquote - headerLevel = paragrahStyle.headerLevel + headIndent = 0 + firstLineHeadIndent = 0 + tailIndent = 0 + paragraphSpacing = 0 + paragraphSpacingBefore = 0 + + baseHeadIndent = paragrahStyle.baseHeadIndent + baseFirstLineHeadIndent = paragrahStyle.baseFirstLineHeadIndent + baseTailIndent = paragrahStyle.baseTailIndent + baseParagraphSpacing = paragrahStyle.baseParagraphSpacing + baseParagraphSpacingBefore = paragrahStyle.baseParagraphSpacingBefore + + properties = paragrahStyle.properties + } + } + + open override var headIndent: CGFloat { + get { + let extra: CGFloat = (CGFloat(lists.count) * Metrics.listTextIndentation) + + return baseHeadIndent + extra + } + + set { + baseHeadIndent = newValue + } + } + + open override var firstLineHeadIndent: CGFloat { + get { + let extra: CGFloat = (CGFloat(lists.count) * Metrics.listTextIndentation) + + return baseFirstLineHeadIndent + extra + } + + set { + baseFirstLineHeadIndent = newValue } } + open override var tailIndent: CGFloat { + get { + let extra: CGFloat = CGFloat(self.blockquotes.count) * Metrics.defaultIndentation + + return baseTailIndent - extra + } + + set { + baseTailIndent = newValue + } + } + + private func calculateExtraParagraphSpacing() -> CGFloat { + return min(((CGFloat(self.blockquotes.count)) + (self.headerLevel == 0 ? 0.0 : 1.0)), 1.0) * Metrics.paragraphSpacing + } + + open override var paragraphSpacing: CGFloat { + get { + let extra = calculateExtraParagraphSpacing() + + return baseParagraphSpacing + extra + } + + set { + baseParagraphSpacing = newValue + } + } + + open override var paragraphSpacingBefore: CGFloat { + get { + let extra = calculateExtraParagraphSpacing() + + return baseParagraphSpacingBefore + extra + } + + set { + baseParagraphSpacingBefore = newValue + } + } + + var baseHeadIndent: CGFloat = 0 + var baseFirstLineHeadIndent: CGFloat = 0 + var baseTailIndent: CGFloat = 0 + var baseParagraphSpacing: CGFloat = 0 + var baseParagraphSpacingBefore: CGFloat = 0 + open override class var `default`: NSParagraphStyle { let style = ParagraphStyle() @@ -75,15 +211,9 @@ open class ParagraphStyle: NSMutableParagraphStyle { return false } - if textList != otherParagraph.textList { - return false - } - - if blockquote != otherParagraph.blockquote { - return false - } - - if headerLevel != otherParagraph.headerLevel { + if headerLevel != otherParagraph.headerLevel + || htmlParagraph != otherParagraph.htmlParagraph + || properties != otherParagraph.properties { return false } @@ -95,37 +225,28 @@ open class ParagraphStyle: NSMutableParagraphStyle { } open override func copy(with zone: NSZone? = nil) -> Any { - let originalCopy = super.copy(with: zone) as! NSParagraphStyle let copy = ParagraphStyle() - copy.setParagraphStyle(originalCopy) - copy.textList = textList - copy.blockquote = blockquote - copy.headerLevel = headerLevel + + copy.setParagraphStyle(self) return copy } open override func mutableCopy(with zone: NSZone? = nil) -> Any { - let originalCopy = super.mutableCopy(with: zone) as! NSParagraphStyle let copy = ParagraphStyle() - copy.setParagraphStyle(originalCopy) - copy.textList = textList - copy.blockquote = blockquote - copy.headerLevel = headerLevel + + copy.setParagraphStyle(self) return copy } open override var hash: Int { var hash: Int = super.hash - if blockquote != nil { - hash = hash ^ String(describing: Blockquote.self).hashValue - } - if let listStyle = textList?.style { - hash = hash ^ listStyle.hashValue - } - hash = hash ^ headerLevel.hashValue + for property in properties { + hash = hash ^ property.hashValue + } + return hash } @@ -133,7 +254,34 @@ open class ParagraphStyle: NSMutableParagraphStyle { return description } - open override var description:String { - return super.description + "\nTextList:\(textList?.style)\nBlockquote:\(blockquote)\nHeaderLevel:\(headerLevel)" + open override var description: String { + return super.description + " Blockquotes: \(String(describing:blockquotes)),\n HeaderLevel: \(headerLevel),\n HTMLParagraph: \(String(describing: htmlParagraph)),\n TextLists: \(lists)" + } +} + +// MARK: - Add method to manipulate properties array + +extension ParagraphStyle { + + func add(property: ParagraphProperty) { + properties.append(property) + } + + func removeProperty(ofType type: AnyClass) { + for index in (0.. CGRect + + /// Returns the Image Representation for a given attachment. + /// + /// - Parameters: + /// - attachment: Attachment to be rendered + /// - size: The Canvas Size + /// + /// - Returns: Optional UIImage instance, representing a given comment. + /// + func attachment(_ attachment: NSTextAttachment, imageForSize size: CGSize) -> UIImage? +} diff --git a/Aztec/Classes/TextKit/TextList.swift b/Aztec/Classes/TextKit/TextList.swift deleted file mode 100644 index 4043e0fbf..000000000 --- a/Aztec/Classes/TextKit/TextList.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -import UIKit - - -// MARK: - Text List -// -class TextList: Equatable -{ - // MARK: - Nested Types - - /// List Styles - /// - enum Style: Int { - case ordered - case unordered - - func markerText(forItemNumber number: Int) -> String { - switch self { - case .ordered: return "\(number).\t" - case .unordered: return "\u{2022}\t\t" - } - } - } - - // MARK: - Properties - - /// Kind of List: Ordered / Unordered - /// - let style: Style - - init(style: Style) { - self.style = style - } - - static func ==(lhs: TextList, rhs: TextList) -> Bool { - return lhs.style == rhs.style - } -} diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index fb32bd749..59538e18f 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -19,23 +19,23 @@ protocol TextStorageAttachmentsDelegate { /// func storage( _ storage: TextStorage, - attachment: TextAttachment, - imageForURL url: URL, + attachment: NSTextAttachment, + imageFor url: URL, onSuccess success: @escaping (UIImage) -> (), onFailure failure: @escaping () -> ()) -> UIImage - func storage(_ storage: TextStorage, missingImageForAttachment: TextAttachment) -> UIImage + func storage(_ storage: TextStorage, missingImageFor attachment: NSTextAttachment) -> UIImage /// Called when an image is about to be added to the storage as an attachment, so that the /// delegate can specify an URL where that image is available. /// /// - Parameters: /// - storage: The storage that is requesting the image. - /// - image: The image that was added to the storage. + /// - imageAttachment: The image that was added to the storage. /// /// - Returns: the requested `NSURL` where the image is stored. /// - func storage(_ storage: TextStorage, urlForAttachment attachment: TextAttachment) -> URL + func storage(_ storage: TextStorage, urlFor imageAttachment: ImageAttachment) -> URL /// Called when a attachment is removed from the storage. /// @@ -43,29 +43,29 @@ protocol TextStorageAttachmentsDelegate { /// - textView: The textView where the attachment was removed. /// - attachmentID: The attachment identifier of the media removed. /// - func storage(_ storage: TextStorage, deletedAttachmentWithID attachmentID: String) + func storage(_ storage: TextStorage, deletedAttachmentWith attachmentID: String) /// Provides the Bounds required to represent a given attachment, within a specified line fragment. /// /// - Parameters: /// - storage: The storage that is requesting the bounds. - /// - attachment: CommentAttachment about to be rendered. + /// - attachment: NSTextAttachment about to be rendered. /// - lineFragment: Line Fragment in which the glyph would be rendered. /// - /// - Returns: Rect specifying the Bounds for the comment attachment + /// - Returns: Rect specifying the Bounds for the attachment /// - func storage(_ storage: TextStorage, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect + func storage(_ storage: TextStorage, boundsFor attachment: NSTextAttachment, with lineFragment: CGRect) -> CGRect /// Provides the (Optional) Image Representation of the specified size, for a given Attachment. /// /// - Parameters: /// - storage: The storage that is requesting the bounds. - /// - attachment: CommentAttachment about to be rendered. + /// - attachment: NSTextAttachment about to be rendered. /// - size: Expected Image Size /// - /// - Returns: (Optional) UIImage representation of the Comment Attachment. + /// - Returns: (Optional) UIImage representation of the attachment. /// - func storage(_ storage: TextStorage, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? + func storage(_ storage: TextStorage, imageFor attachment: NSTextAttachment, with size: CGSize) -> UIImage? } @@ -74,39 +74,6 @@ protocol TextStorageAttachmentsDelegate { open class TextStorage: NSTextStorage { fileprivate var textStore = NSMutableAttributedString(string: "", attributes: nil) - fileprivate let dom = Libxml2.DOMString() - - // MARK: - Visual only elements - - private let visualOnlyElementFactory = VisualOnlyElementFactory() - - // MARK: - Undo Support - - public var undoManager: UndoManager? { - get { - return dom.undoManager - } - - set { - dom.undoManager = newValue - } - } - - /// Call this method to know if the DOM should be updated, or if the undo manager will take care - /// of it. - /// - /// The undo manager will take care of updating the DOM whenever an undo or redo operation - /// is triggered. - /// - /// - Returns: `true` if the DOM must be updated, or `false` if the undo manager will take care. - /// - private func mustUpdateDOM() -> Bool { - guard let undoManager = undoManager else { - return true - } - - return !undoManager.isUndoing - } // MARK: - NSTextStorage @@ -118,11 +85,11 @@ open class TextStorage: NSTextStorage { var attachmentsDelegate: TextStorageAttachmentsDelegate! - open func TextAttachments() -> [TextAttachment] { + open var mediaAttachments: [MediaAttachment] { let range = NSMakeRange(0, length) - var attachments = [TextAttachment]() + var attachments = [MediaAttachment]() enumerateAttribute(NSAttachmentAttributeName, in: range, options: []) { (object, range, stop) in - if let attachment = object as? TextAttachment { + if let attachment = object as? MediaAttachment { attachments.append(attachment) } } @@ -130,11 +97,10 @@ open class TextStorage: NSTextStorage { return attachments } - open func range(forAttachment attachment: TextAttachment) -> NSRange? { - + func range(for attachment: T) -> NSRange? { var range: NSRange? - textStore.enumerateAttachmentsOfType(TextAttachment.self) { (currentAttachment, currentRange, stop) in + textStore.enumerateAttachmentsOfType(T.self) { (currentAttachment, currentRange, stop) in if attachment == currentAttachment { range = currentRange stop.pointee = true @@ -148,42 +114,8 @@ open class TextStorage: NSTextStorage { private func preprocessAttributesForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { let stringWithAttachments = preprocessAttachmentsForInsertion(attributedString) - let stringWithParagraphs = preprocessParagraphsForInsertion(stringWithAttachments) - - return stringWithParagraphs - } - - private func preprocessParagraphsForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { - - let fullRange = NSRange(location: 0, length: attributedString.length) - let finalString = NSMutableAttributedString(attributedString: attributedString) - - attributedString.enumerateAttribute(NSParagraphStyleAttributeName, in: fullRange, options: []) { (value, subRange, stop) in - - guard let paragraphStyle = value as? ParagraphStyle else { - return - } - - if paragraphStyle.textList != nil { - var newlineRange = finalString.mutableString.range(of: String(.newline)) - - while newlineRange.location != NSNotFound { - - let originalAttributes = finalString.attributes(at: newlineRange.location, effectiveRange: nil) - let visualOnlyNewline = visualOnlyElementFactory.newline(inheritingAttributes: originalAttributes) - - finalString.replaceCharacters(in: newlineRange, with: visualOnlyNewline) - - let nextLocation = newlineRange.location + newlineRange.length - let nextLength = subRange.length - nextLocation - let nextRange = NSRange(location: nextLocation, length: nextLength) - - newlineRange = finalString.mutableString.range(of: String(.newline), options: [], range: nextRange) - } - } - } - return finalString + return stringWithAttachments } /// Preprocesses an attributed string's attachments for insertion in the storage. @@ -217,7 +149,11 @@ open class TextStorage: NSTextStorage { break case let attachment as CommentAttachment: attachment.delegate = self - case let attachment as TextAttachment: + case let attachment as HTMLAttachment: + attachment.delegate = self + case let attachment as ImageAttachment: + attachment.delegate = self + case let attachment as VideoAttachment: attachment.delegate = self default: guard let image = textAttachment.image else { @@ -228,10 +164,10 @@ open class TextStorage: NSTextStorage { return } - let replacementAttachment = TextAttachment() + let replacementAttachment = ImageAttachment(identifier: NSUUID.init().uuidString) replacementAttachment.delegate = self replacementAttachment.image = image - replacementAttachment.url = attachmentsDelegate.storage(self, urlForAttachment: replacementAttachment) + replacementAttachment.url = attachmentsDelegate.storage(self, urlFor: replacementAttachment) finalString.addAttribute(NSAttachmentAttributeName, value: replacementAttachment, range: range) } @@ -241,56 +177,45 @@ open class TextStorage: NSTextStorage { } fileprivate func detectAttachmentRemoved(in range:NSRange) { - textStore.enumerateAttachmentsOfType(TextAttachment.self, range: range) { (attachment, range, stop) in - self.attachmentsDelegate.storage(self, deletedAttachmentWithID: attachment.identifier) + textStore.enumerateAttachmentsOfType(MediaAttachment.self, range: range) { (attachment, range, stop) in + self.attachmentsDelegate.storage(self, deletedAttachmentWith: attachment.identifier) } } // MARK: - Overriden Methods + /// Retrieves the attributes for the requested character location. + /// + /// - Important: please note that this method returns the style at the character location, and + /// NOT at the caret location. For N characters we always have N+1 character locations. + /// override open func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [String : Any] { - return textStore.length == 0 ? [:] : textStore.attributes(at: location, effectiveRange: range) - } + guard textStore.length > 0 else { + return [:] + } + + return textStore.attributes(at: location, effectiveRange: range) + } + override open func replaceCharacters(in range: NSRange, with str: String) { beginEditing() - if mustUpdateDOM() { - let targetDomRange = map(visualRange: range) - let preferLeftNode = doesPreferLeftNode(atCaretPosition: range.location) - - dom.replaceCharacters(inRange: targetDomRange, withString: str, preferLeftNode: preferLeftNode) - } - detectAttachmentRemoved(in: range) textStore.replaceCharacters(in: range, with: str) - let nsString = str as NSString - edited(.editedCharacters, range: range, changeInLength: nsString.length - range.length) + + edited(.editedCharacters, range: range, changeInLength: str.characters.count - range.length) endEditing() } - + override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { let preprocessedString = preprocessAttributesForInsertion(attrString) beginEditing() - if mustUpdateDOM() { - let targetDomRange = map(visualRange: range) - let preferLeftNode = doesPreferLeftNode(atCaretPosition: range.location) - - let domString = preprocessedString.filter(attributeNamed: VisualOnlyAttributeName) - dom.replaceCharacters(inRange: targetDomRange, withString: domString.string, preferLeftNode: preferLeftNode) - - if targetDomRange.length != range.length { - dom.deleteBlockSeparator(at: targetDomRange.location) - } - - applyStylesToDom(from: domString, startingAt: range.location) - } - detectAttachmentRemoved(in: range) textStore.replaceCharacters(in: range, with: preprocessedString) edited([.editedAttributes, .editedCharacters], range: range, changeInLength: attrString.length - range.length) @@ -301,395 +226,16 @@ open class TextStorage: NSTextStorage { override open func setAttributes(_ attrs: [String : Any]?, range: NSRange) { beginEditing() - if mustUpdateDOM(), let attributes = attrs { - applyStylesToDom(attributes: attributes, in: range) - } - textStore.setAttributes(attrs, range: range) edited(.editedAttributes, range: range, changeInLength: 0) endEditing() } - // MARK: - Entry point for calculating style differences - - /// This method applies the styles in the specified attributes dictionary, to the DOM in the - /// specified range. To do so, it calculates the differences and applies them. - /// - /// - Parameters: - /// - attributes: the attributes to apply. - /// - range: the range to apply the styles to. - /// - private func applyStylesToDom(attributes: [String : Any], in range: NSRange) { - textStore.enumerateAttributeDifferences(in: range, against: attributes, do: { (subRange, key, sourceValue, targetValue) in - - let domRange = map(visualRange: subRange) - - processAttributesDifference(in: domRange, key: key, sourceValue: sourceValue, targetValue: targetValue) - }) - } - - /// This method applies the styles from the specified attributed string, to the DOM, starting - /// at the specified location, and moving ahead for the length of the attributed string. - /// - /// This makes sense only if the attributed string has already been added to the DOM, as a way - /// to apply the styles for that string. - /// - /// - Parameters: - /// - attributedString: the attributed string containing the styles we want to apply. - /// - location: the starting location where the styles should be applied in the DOM. - /// It's the offset this method will use to apply the styles found in the source string. - /// - private func applyStylesToDom(from attributedString: NSAttributedString, startingAt location: Int) { - let originalAttributes = location < textStore.length ? textStore.attributes(at: location, effectiveRange: nil) : [:] - let fullRange = NSRange(location: 0, length: attributedString.length) - - let domLocation = map(visualLocation: location) - - attributedString.enumerateAttributeDifferences(in: fullRange, against: originalAttributes, do: { (subRange, key, sourceValue, targetValue) in - // The source and target values are inverted since we're enumerating on the new string. - - let domRange = NSRange(location: domLocation + subRange.location, length: subRange.length) - - processAttributesDifference(in: domRange, key: key, sourceValue: targetValue, targetValue: sourceValue) - }) - } - - /// Check the difference in styles and applies the necessary changes to the DOM string. - /// - /// - Parameters: - /// - domRange: the range to check - /// - key: the attribute style key - /// - sourceValue: the original value of the attribute - /// - targetValue: the new value of the attribute - /// - private func processAttributesDifference(in domRange: NSRange, key: String, sourceValue: Any?, targetValue: Any?) { - let isLineAttachment = sourceValue is LineAttachment || targetValue is LineAttachment - let isCommentAttachment = sourceValue is CommentAttachment || targetValue is CommentAttachment - - switch(key) { - case NSFontAttributeName: - let sourceFont = sourceValue as? UIFont - let targetFont = targetValue as? UIFont - - processFontDifferences(in: domRange, betweenOriginal: sourceFont, andNew: targetFont) - case NSStrikethroughStyleAttributeName: - let sourceStyle = sourceValue as? NSNumber - let targetStyle = targetValue as? NSNumber - - processStrikethroughDifferences(in: domRange, betweenOriginal: sourceStyle, andNew: targetStyle) - case NSUnderlineStyleAttributeName: - let sourceStyle = sourceValue as? NSNumber - let targetStyle = targetValue as? NSNumber - - processUnderlineDifferences(in: domRange, betweenOriginal: sourceStyle, andNew: targetStyle) - case NSAttachmentAttributeName where isLineAttachment: - let sourceAttachment = sourceValue as? LineAttachment - let targetAttachment = targetValue as? LineAttachment - - processLineAttachmentDifferences(in: domRange, betweenOriginal: sourceAttachment, andNew: targetAttachment) - case NSAttachmentAttributeName where isCommentAttachment: - let sourceAttachment = sourceValue as? CommentAttachment - let targetAttachment = targetValue as? CommentAttachment - - processCommentAttachmentDifferences(in: domRange, betweenOriginal: sourceAttachment, andNew: targetAttachment) - case NSAttachmentAttributeName: - let sourceAttachment = sourceValue as? TextAttachment - let targetAttachment = targetValue as? TextAttachment - - processAttachmentDifferences(in: domRange, betweenOriginal: sourceAttachment, andNew: targetAttachment) - case NSParagraphStyleAttributeName: - let sourceStyle = sourceValue as? ParagraphStyle - let targetStyle = targetValue as? ParagraphStyle - processBlockquoteDifferences(in: domRange, betweenOriginal: sourceStyle?.blockquote, andNew: targetStyle?.blockquote) - - processHeaderDifferences(in: domRange, betweenOriginal: sourceStyle?.headerLevel, andNew: targetStyle?.headerLevel) - case NSLinkAttributeName: - let sourceStyle = sourceValue as? URL - let targetStyle = targetValue as? URL - processLinkDifferences(in: domRange, betweenOriginal: sourceStyle, andNew: targetStyle) - default: - break - } - } - - // MARK: - Calculating and applying style differences - - /// Processes differences in a font object, and applies them to the DOM in the specified range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalFont: the original font object. - /// - newFont: the new font object. - /// - private func processFontDifferences(in range: NSRange, betweenOriginal originalFont: UIFont?, andNew newFont: UIFont?) { - processBoldDifferences(in: range, betweenOriginal: originalFont, andNew: newFont) - processItalicDifferences(in: range, betweenOriginal: originalFont, andNew: newFont) - } - - /// Processes differences in the bold trait of two font objects, and applies them to the DOM in - /// the specified range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalFont: the original font object. - /// - newFont: the new font object. - /// - private func processBoldDifferences(in range: NSRange, betweenOriginal originalFont: UIFont?, andNew newFont: UIFont?) { - let oldIsBold = originalFont?.containsTraits(.traitBold) ?? false - let newIsBold = newFont?.containsTraits(.traitBold) ?? false - - let addBold = !oldIsBold && newIsBold - let removeBold = oldIsBold && !newIsBold - - if addBold { - dom.applyBold(spanning: range) - } else if removeBold { - dom.removeBold(spanning: range) - } - } - - /// Process difference in attachmente properties, and applies them to the DOM in the specified range - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - original: the original attachment existing in the range if any. - /// - new: the new attachment to apply to the range if any. - /// - private func processAttachmentDifferences(in range: NSRange, betweenOriginal original: TextAttachment?, andNew new: TextAttachment?) { - - let originalUrl = original?.url - let newUrl = new?.url - - let addImageUrl = originalUrl == nil && newUrl != nil - let removeImageUrl = originalUrl != nil && newUrl == nil - - if addImageUrl { - guard let urlToAdd = newUrl else { - assertionFailure("This should not be possible. Review your logic.") - return - } - - dom.replace(range, with: urlToAdd) - } else if removeImageUrl { - dom.removeImage(spanning: range) - } - } - - private func processLineAttachmentDifferences(in range: NSRange, betweenOriginal original: LineAttachment?, andNew new: LineAttachment?) { - - dom.replaceWithHorizontalRuler(range) - } - - private func processCommentAttachmentDifferences(in range: NSRange, betweenOriginal original: CommentAttachment?, andNew new: CommentAttachment?) { - guard let newAttachment = new else { - return - } - - dom.replace(range, with: newAttachment.text) - } - - - /// Processes differences in the italic trait of two font objects, and applies them to the DOM - /// in the specified range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalFont: the original font object. - /// - newFont: the new font object. - /// - private func processItalicDifferences(in range: NSRange, betweenOriginal originalFont: UIFont?, andNew newFont: UIFont?) { - let oldIsItalic = originalFont?.containsTraits(.traitItalic) ?? false - let newIsItalic = newFont?.containsTraits(.traitItalic) ?? false - - let addItalic = !oldIsItalic && newIsItalic - let removeItalic = oldIsItalic && !newIsItalic - - if addItalic { - dom.applyItalic(spanning: range) - } else if removeItalic { - dom.removeItalic(spanning: range) - } - } - - /// Processes differences in two strikethrough styles, and applies them to the DOM in the - /// specified range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalFont: the original font object. - /// - newFont: the new font object. - /// - private func processStrikethroughDifferences(in range: NSRange, betweenOriginal originalStyle: NSNumber?, andNew newStyle: NSNumber?) { - - let sourceStyle = originalStyle ?? 0 - let targetStyle = newStyle ?? 0 - - // At some point we'll support different styles. For now we only check if ANY style is - // set. - // - let addStyle = sourceStyle == 0 && targetStyle == 1 - let removeStyle = sourceStyle == 1 && targetStyle == 0 - - if addStyle { - dom.applyStrikethrough(spanning: range) - } else if removeStyle { - dom.removeStrikethrough(spanning: range) - } - } - - /// Processes differences in two underline styles, and applies them to the DOM in the specified - /// range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalFont: the original font object. - /// - newFont: the new font object. - /// - private func processUnderlineDifferences(in range: NSRange, betweenOriginal originalStyle: NSNumber?, andNew newStyle: NSNumber?) { - - let sourceStyle = originalStyle ?? 0 - let targetStyle = newStyle ?? 0 - - // At some point we'll support different styles. For now we only check if ANY style is - // set. - // - let addStyle = sourceStyle == 0 && targetStyle == 1 - let removeStyle = sourceStyle == 1 && targetStyle == 0 - - if addStyle { - dom.applyUnderline(spanning: range) - } else if removeStyle { - dom.removeUnderline(spanning: range) - } - } - - /// Processes differences in blockquote styles, and applies them to the DOM in the specified - /// range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalStyle: the original Blockquote object if any. - /// - newStyle: the new Blockquote object. - /// - private func processBlockquoteDifferences(in range: NSRange, betweenOriginal originalStyle: Blockquote?, andNew newStyle: Blockquote?) { - - let addStyle = originalStyle == nil && newStyle != nil - let removeStyle = originalStyle != nil && newStyle == nil - - if addStyle { - dom.applyBlockquote(spanning: range) - } else if removeStyle { - dom.removeBlockquote(spanning: range) - } - } - - /// Processes differences in header styles, and applies them to the DOM in the specified - /// range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalHeaderLevel: the original font object. - /// - newHeaderLevel: the new font object. - /// - private func processHeaderDifferences(in range: NSRange, betweenOriginal originalHeaderLevel: Int?, andNew newHeaderLevel: Int?) { - - let sourceHeader = originalHeaderLevel ?? 0 - let targetHeader = newHeaderLevel ?? 0 - - let addStyle = sourceHeader == 0 && targetHeader > 0 - let removeStyle = sourceHeader > 0 && targetHeader == 0 - - if addStyle { - dom.applyHeader(targetHeader, spanning: range) - } else if removeStyle { - dom.removeHeader(sourceHeader, spanning: range) - } - } - - /// Processes differences in link styles, and applies them to the DOM in the specified - /// range. - /// - /// - Parameters: - /// - range: the range in the DOM where the differences must be applied. - /// - originalURL: the original link URL object if any. - /// - newURL: the new link URL object if any. - /// - private func processLinkDifferences(in range: NSRange, betweenOriginal originalURL: URL?, andNew newURL: URL?) { - - let addStyle = originalURL == nil && newURL != nil - let removeStyle = originalURL != nil && newURL == nil - - if addStyle { - dom.applyLink(newURL, spanning: range) - } else if removeStyle { - dom.removeLink(spanning: range) - } - } - - - // MARK: - Range Mapping: Visual vs HTML - - private func canAppendToNodeRepresentedByCharacter(atIndex index: Int) -> Bool { - return !hasNewLine(atIndex: index) - && !hasHorizontalLine(atIndex: index) - && !hasCommentMarker(atIndex: index) - && !hasVisualOnlyElement(atIndex: index) - } - - private func doesPreferLeftNode(atCaretPosition caretPosition: Int) -> Bool { - guard caretPosition != 0, - let previousLocation = textStore.string.location(before:caretPosition) else { - return false - } - - return canAppendToNodeRepresentedByCharacter(atIndex: previousLocation) - } - - private func hasHorizontalLine(atIndex index: Int) -> Bool { - guard let attachment = attribute(NSAttachmentAttributeName, at: index, effectiveRange: nil), - attachment is LineAttachment else { - return false - } - - return true - } - - private func hasCommentMarker(atIndex index: Int) -> Bool { - guard let attachment = attribute(NSAttachmentAttributeName, at: index, effectiveRange: nil), - attachment is CommentAttachment else { - return false - } - - return true - } - - private func hasNewLine(atIndex index: Int) -> Bool { - if index >= textStore.length || index < 0 { - return false - } - let nsString = string as NSString - return nsString.substring(from: index).hasPrefix(String(Character(.newline))) - } - - private func hasVisualOnlyElement(atIndex index: Int) -> Bool { - return attribute(VisualOnlyAttributeName, at: index, effectiveRange: nil) != nil - } - - private func map(visualLocation: Int) -> Int { - - let locationRange = NSRange(location: visualLocation, length: 0) - let mappedRange = textStore.map(range: locationRange, bySubtractingAttributeNamed: VisualOnlyAttributeName) - - return mappedRange.location - } - - private func map(visualRange: NSRange) -> NSRange { - return textStore.map(range: visualRange, bySubtractingAttributeNamed: VisualOnlyAttributeName) - } - // MARK: - Styles: Toggling - @discardableResult func toggle(formatter: AttributeFormatter, at range: NSRange) -> NSRange { + + @discardableResult + func toggle(formatter: AttributeFormatter, at range: NSRange) -> NSRange { let applicationRange = formatter.applicationRange(for: range, in: self) if applicationRange.length == 0, !formatter.worksInEmptyRange() { return applicationRange @@ -698,50 +244,16 @@ open class TextStorage: NSTextStorage { return formatter.toggle(in: self, at: applicationRange) } - /// Insert Image Element at the specified range using url as source - /// - /// - parameter url: the source URL of the image - /// - parameter position: the position to insert the image - /// - parameter placeHolderImage: an image to display while the image from sourceURL is being prepared - /// - /// - returns: the attachment object that was created and inserted on the text - /// - func insertImage(sourceURL url: URL, atPosition position:Int, placeHolderImage: UIImage, identifier: String = UUID().uuidString) -> TextAttachment { - let attachment = TextAttachment(identifier: identifier) - attachment.delegate = self - attachment.url = url - attachment.image = placeHolderImage - - // Inject the Attachment and Layout - let insertionRange = NSMakeRange(position, 0) - let attachmentString = NSAttributedString(attachment: attachment) - replaceCharacters(in: insertionRange, with: attachmentString) - - return attachment - } - - /// Insert an HR element at the specifice range - /// - /// - Parameter range: the range where the element will be inserted - /// - func replaceRangeWithHorizontalRuler(_ range: NSRange) { - let line = LineAttachment() - - let attachmentString = NSAttributedString(attachment: line) - replaceCharacters(in: range, with: attachmentString) - } - // MARK: - Attachments - /// Return the attachment, if any, corresponding to the id provided /// /// - Parameter id: the unique id of the attachment /// - Returns: the attachment object /// - open func attachment(withId id: String) -> TextAttachment? { - var foundAttachment: TextAttachment? = nil - enumerateAttachmentsOfType(TextAttachment.self) { (attachment, range, stop) in + func attachment(withId id: String) -> MediaAttachment? { + var foundAttachment: MediaAttachment? = nil + enumerateAttachmentsOfType(MediaAttachment.self) { (attachment, range, stop) in if attachment.identifier == id { foundAttachment = attachment stop.pointee = true @@ -759,40 +271,50 @@ open class TextStorage: NSTextStorage { /// - size: the size to use /// - url: the image URL for the image /// - open func update(attachment: TextAttachment, - alignment: TextAttachment.Alignment, - size: TextAttachment.Size, - url: URL) { + func update(attachment: ImageAttachment, + alignment: ImageAttachment.Alignment, + size: ImageAttachment.Size, + url: URL) { attachment.alignment = alignment attachment.size = size attachment.url = url - let rangesForAttachment = ranges(forAttachment:attachment) + } - let domRanges = rangesForAttachment.map { range -> NSRange in - map(visualRange: range) + /// Updates the specified HTMLAttachment with new HTML contents + /// + func update(attachment: HTMLAttachment, html: String) { + guard let range = range(for: attachment) else { + assertionFailure("Couldn't determine the range for an Attachment") + return } - - dom.updateImage(spanning: domRanges, url: url, size: size, alignment: alignment) + + attachment.rawHTML = html + + let stringWithAttachment = NSAttributedString(attachment: attachment) + replaceCharacters(in: range, with: stringWithAttachment) } - /// Removes the attachments that match the attachament identifier provided from the storage + /// Return the range of an attachment with the specified identifier if any /// - /// - Parameter attachmentID: the unique id of the attachment + /// - Parameter attachmentID: the id of the attachment + /// - Returns: the range of the attachment /// - open func remove(attachmentID: String) { - enumerateAttachmentsOfType(TextAttachment.self) { (attachment, range, stop) in + open func rangeFor(attachmentID: String) -> NSRange? { + var foundRange: NSRange? + enumerateAttachmentsOfType(MediaAttachment.self) { (attachment, range, stop) in if attachment.identifier == attachmentID { - self.replaceCharacters(in: range, with: NSAttributedString(string: "")) + foundRange = range stop.pointee = true } } + return foundRange } /// Removes all of the TextAttachments from the storage /// - open func removeTextAttachments() { + open func removeMediaAttachments() { var ranges = [NSRange]() - enumerateAttachmentsOfType(TextAttachment.self) { (attachment, range, _) in + enumerateAttachmentsOfType(MediaAttachment.self) { (attachment, range, _) in ranges.append(range) } @@ -804,77 +326,37 @@ open class TextStorage: NSTextStorage { } } - /// Inserts the Comment Attachment at the specified position - /// - @discardableResult - open func replaceRangeWithCommentAttachment(_ range: NSRange, text: String, attributes: [String: Any]) -> CommentAttachment { - let attachment = CommentAttachment() - attachment.text = text - - let stringWithAttachment = NSAttributedString(attachment: attachment, attributes: attributes) - replaceCharacters(in: range, with: stringWithAttachment) - - return attachment - } - + // MARK: - HTML Interaction - // MARK: - Toggle Attributes + open func getHTML(prettyPrint: Bool = false) -> String { + let converter = NSAttributedStringToNodes() + let rootNode = converter.convert(self) - fileprivate func toggleAttribute(_ attributeName: String, value: AnyObject, range: NSRange) { + let serializer = Libxml2.Out.HTMLConverter(prettyPrint: prettyPrint) + return serializer.convert(rootNode) - var effectiveRange = NSRange() - let enable: Bool - - if attribute(attributeName, at: range.location, longestEffectiveRange: &effectiveRange, in: range) != nil { - let intersection = range.intersect(withRange: effectiveRange) - - if let intersection = intersection { - enable = !NSEqualRanges(range, intersection) - } else { - enable = true - } - } else { - enable = true - } - - if enable { - addAttribute(attributeName, value: value, range: range) - } else { - - /// We should be calculating what attributes to remove in `TextStorage.setAttributes()` - /// but since that may take a while to implement, we need this workaround until it's ready. - /// - switch attributeName { - case NSStrikethroughStyleAttributeName: - dom.removeStrikethrough(spanning: range) - case NSUnderlineStyleAttributeName: - dom.removeUnderline(spanning: range) - default: - break - } - - removeAttribute(attributeName, range: range) - } - } - - // MARK: - HTML Interaction - - open func getHTML() -> String { - return dom.getHTML() } func setHTML(_ html: String, withDefaultFontDescriptor defaultFontDescriptor: UIFontDescriptor) { - - let attributedString = dom.setHTML(html, withDefaultFontDescriptor: defaultFontDescriptor) - + let originalLength = textStore.length + let converter = HTMLToAttributedString(usingDefaultFontDescriptor: defaultFontDescriptor) + + let (_, attributedString) = converter.convert(html) textStore = NSMutableAttributedString(attributedString: attributedString) - textStore.enumerateAttachmentsOfType(TextAttachment.self) { [weak self] (attachment, _, _) in + + textStore.enumerateAttachmentsOfType(ImageAttachment.self) { [weak self] (attachment, _, _) in + attachment.delegate = self + } + textStore.enumerateAttachmentsOfType(VideoAttachment.self) { [weak self] (attachment, _, _) in attachment.delegate = self } textStore.enumerateAttachmentsOfType(CommentAttachment.self) { [weak self] (attachment, _, _) in attachment.delegate = self } + textStore.enumerateAttachmentsOfType(HTMLAttachment.self) { [weak self] (attachment, _, _) in + attachment.delegate = self + } edited([.editedAttributes, .editedCharacters], range: NSRange(location: 0, length: originalLength), changeInLength: textStore.length - originalLength) } @@ -883,32 +365,52 @@ open class TextStorage: NSTextStorage { // MARK: - TextStorage: TextAttachmentDelegate Methods // -extension TextStorage: TextAttachmentDelegate { +extension TextStorage: MediaAttachmentDelegate { + + func mediaAttachmentPlaceholderImageFor(attachment: MediaAttachment) -> UIImage { + assert(attachmentsDelegate != nil) + return attachmentsDelegate.storage(self, missingImageFor: attachment) + } + - func textAttachment( - _ textAttachment: TextAttachment, + func mediaAttachment( + _ mediaAttachment: MediaAttachment, imageForURL url: URL, onSuccess success: @escaping (UIImage) -> (), onFailure failure: @escaping () -> ()) -> UIImage { assert(attachmentsDelegate != nil) - return attachmentsDelegate.storage(self, attachment: textAttachment, imageForURL: url, onSuccess: success, onFailure: failure) + return attachmentsDelegate.storage(self, attachment: mediaAttachment, imageFor: url, onSuccess: success, onFailure: failure) } +} + +extension TextStorage: VideoAttachmentDelegate { + func videoAttachment( + _ videoAttachment: VideoAttachment, + imageForURL url: URL, + onSuccess success: @escaping (UIImage) -> (), + onFailure failure: @escaping () -> ()) -> UIImage + { + assert(attachmentsDelegate != nil) + return attachmentsDelegate.storage(self, attachment: videoAttachment, imageFor: url, onSuccess: success, onFailure: failure) + } + } -// MARK: - TextStorage: CommentAttachmentDelegate Methods + +// MARK: - TextStorage: RenderableAttachmentDelegate Methods // -extension TextStorage: CommentAttachmentDelegate { +extension TextStorage: RenderableAttachmentDelegate { - func commentAttachment(_ commentAttachment: CommentAttachment, imageForSize size: CGSize) -> UIImage? { + func attachment(_ attachment: NSTextAttachment, imageForSize size: CGSize) -> UIImage? { assert(attachmentsDelegate != nil) - return attachmentsDelegate.storage(self, imageForComment: commentAttachment, with: size) + return attachmentsDelegate.storage(self, imageFor: attachment, with: size) } - func commentAttachment(_ commentAttachment: CommentAttachment, boundsForLineFragment fragment: CGRect) -> CGRect { + func attachment(_ attachment: NSTextAttachment, boundsForLineFragment fragment: CGRect) -> CGRect { assert(attachmentsDelegate != nil) - return attachmentsDelegate.storage(self, boundsForComment: commentAttachment, with: fragment) + return attachmentsDelegate.storage(self, boundsFor: attachment, with: fragment) } } diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 350b7e2a5..bacdc3429 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -1,17 +1,17 @@ import UIKit import Foundation -import Gridicons -// MARK: - TextViewMediaDelegate +// MARK: - TextViewAttachmentDelegate // -public protocol TextViewMediaDelegate: class { +public protocol TextViewAttachmentDelegate: class { /// This method requests from the delegate the image at the specified URL. /// /// - Parameters: /// - textView: the `TextView` the call has been made from. + /// - attachment: the attachment that is requesting the image /// - imageURL: the url to download the image from. /// - success: when the image is obtained, this closure should be executed. /// - failure: if the image cannot be obtained, this closure should be executed. @@ -19,78 +19,95 @@ public protocol TextViewMediaDelegate: class { /// - Returns: the placeholder for the requested image. Also useful if showing low-res versions /// of the images. /// - func textView( - _ textView: TextView, - imageAtUrl imageURL: URL, - onSuccess success: @escaping (UIImage) -> Void, - onFailure failure: @escaping (Void) -> Void) -> UIImage + func textView(_ textView: TextView, + attachment: NSTextAttachment, + imageAt url: URL, + onSuccess success: @escaping (UIImage) -> Void, + onFailure failure: @escaping () -> Void) -> UIImage /// Called when an attachment is about to be added to the storage as an attachment (copy/paste), so that the /// delegate can specify an URL where that attachment is available. /// /// - Parameters: /// - textView: The textView that is requesting the image. - /// - attachment: The attachment that was added to the storage. + /// - imageAttachment: The image attachment that was added to the storage. /// /// - Returns: the requested `NSURL` where the image is stored. /// - func textView( - _ textView: TextView, - urlForAttachment attachment: TextAttachment) -> URL + func textView(_ textView: TextView, urlFor imageAttachment: ImageAttachment) -> URL + /// Called when an attachment doesn't have an available source URL to provide an image representation. + /// + /// - Parameters: + /// - textView: the textview that is requesting the image + /// - attachment: the attachment that does not an have image source + /// - Returns: an UIImage to represent the attachment graphically + func textView(_ textView: TextView, + placeholderForAttachment attachment: NSTextAttachment) -> UIImage - /// Called when a attachment is removed from the storage. + /// Called after a attachment is removed from the storage. /// /// - Parameters: /// - textView: The textView where the attachment was removed. /// - attachmentID: The attachment identifier of the media removed. - func textView(_ textView: TextView, deletedAttachmentWithID attachmentID: String) + /// + func textView(_ textView: TextView, deletedAttachmentWith attachmentID: String) - /// Called when an attachment is selected with a single tap. + /// Called after an attachment is selected with a single tap. /// /// - Parameters: /// - textView: the textview where the attachment is. /// - attachment: the attachment that was selected. /// - position: touch position relative to the textview. /// - func textView(_ textView: TextView, selectedAttachment attachment: TextAttachment, atPosition position: CGPoint) + func textView(_ textView: TextView, selected attachment: NSTextAttachment, atPosition position: CGPoint) - /// Called when an attachment is deselected with a single tap. + /// Called after an attachment is deselected with a single tap. /// /// - Parameters: /// - textView: the textview where the attachment is. /// - attachment: the attachment that was deselected. /// - position: touch position relative to the textView /// - func textView(_ textView: TextView, deselectedAttachment attachment: TextAttachment, atPosition position: CGPoint) + func textView(_ textView: TextView, deselected attachment: NSTextAttachment, atPosition position: CGPoint) } -// MARK: - TextViewCommentsDelegate +// MARK: - TextViewAttachmentImageProvider // -public protocol TextViewCommentsDelegate: class { +public protocol TextViewAttachmentImageProvider: class { + + /// Indicates whether the current Attachment Renderer supports a given NSTextAttachment instance, or not. + /// + /// - Parameters: + /// - textView: The textView that is requesting the bounds. + /// - attachment: Attachment about to be rendered. + /// + /// - Returns: True when supported, false otherwise. + /// + func textView(_ textView: TextView, shouldRender attachment: NSTextAttachment) -> Bool /// Provides the Bounds required to represent a given attachment, within a specified line fragment. /// /// - Parameters: /// - textView: The textView that is requesting the bounds. - /// - attachment: CommentAttachment about to be rendered. + /// - attachment: Attachment about to be rendered. /// - lineFragment: Line Fragment in which the glyph would be rendered. /// /// - Returns: Rect specifying the Bounds for the comment attachment /// - func textView(_ textView: TextView, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect + func textView(_ textView: TextView, boundsFor attachment: NSTextAttachment, with lineFragment: CGRect) -> CGRect /// Provides the (Optional) Image Representation of the specified size, for a given Attachment. /// /// - Parameters: /// - textView: The textView that is requesting the bounds. - /// - attachment: CommentAttachment about to be rendered. + /// - attachment: Attachment about to be rendered. /// - size: Expected Image Size /// /// - Returns: (Optional) UIImage representation of the Comment Attachment. /// - func textView(_ textView: TextView, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? + func textView(_ textView: TextView, imageFor attachment: NSTextAttachment, with size: CGSize) -> UIImage? } @@ -118,16 +135,20 @@ open class TextView: UITextView { /// The media delegate takes care of providing remote media when requested by the `TextView`. /// If this is not set, all remove images will be left blank. /// - open weak var mediaDelegate: TextViewMediaDelegate? + open weak var textAttachmentDelegate: TextViewAttachmentDelegate? - // MARK: - Properties: Comment Attachments - - open weak var commentsDelegate: TextViewCommentsDelegate? + /// Maintains a reference to the user provided Text Attachment Image Providers + /// + fileprivate var textAttachmentImageProvider = [TextViewAttachmentImageProvider]() // MARK: - Properties: Formatting open weak var formattingDelegate: TextViewFormattingDelegate? + // MARK: - Properties: Text Lists + + var maximumListIndentationLevels = 7 + // MARK: - Properties: GUI Defaults let defaultFont: UIFont @@ -139,13 +160,30 @@ open class TextView: UITextView { return textStorage as! TextStorage } + // MARK: - Overwritten Properties + + /// Overwrites Typing Attributes: + /// This is the (only) valid hook we've found, in order to (selectively) remove the [Blockquote, List, Pre] attributes. + /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 + /// + override open var typingAttributes: [String: Any] { + get { + ensureRemovalOfParagraphAttributesAfterSelectionChange() + return super.typingAttributes + } + + set { + super.typingAttributes = newValue + } + } + // MARK: - Init & deinit public init(defaultFont: UIFont, defaultMissingImage: UIImage) { self.defaultFont = defaultFont self.defaultMissingImage = defaultMissingImage - + let storage = TextStorage() let layoutManager = LayoutManager() let container = NSTextContainer() @@ -155,14 +193,13 @@ open class TextView: UITextView { container.widthTracksTextView = true super.init(frame: CGRect(x: 0, y: 0, width: 10, height: 10), textContainer: container) - storage.undoManager = undoManager commonInit() } - + required public init?(coder aDecoder: NSCoder) { defaultFont = UIFont.systemFont(ofSize: 14) - defaultMissingImage = Gridicon.iconOfType(.image) + defaultMissingImage = Assets.imageIcon super.init(coder: aDecoder) commonInit() } @@ -203,13 +240,24 @@ open class TextView: UITextView { // MARK: - Intercept copy paste operations + private let unsupportedCopyAttributes: [Any.Type] = [HTMLElementRepresentation.self, UnsupportedHTML.self] + + open override func cut(_ sender: Any?) { + // FIXME: This is a temporary workaround for Issue #626 + let substring = storage.attributedSubstring(from: selectedRange).stripAttributes(of: unsupportedCopyAttributes) + let data = substring.archivedData() + super.cut(sender) + + storeInPasteboard(encoded: data) + } + open override func copy(_ sender: Any?) { + // FIXME: This is a temporary workaround for Issue #626 + let substring = storage.attributedSubstring(from: selectedRange).stripAttributes(of: unsupportedCopyAttributes) + let data = substring.archivedData() super.copy(sender) - let data = self.storage.attributedSubstring(from: selectedRange).archivedData() - let pasteboard = UIPasteboard.general - var items = pasteboard.items - items[0][NSAttributedString.pastesboardUTI] = data - pasteboard.items = items + + storeInPasteboard(encoded: data) } open override func paste(_ sender: Any?) { @@ -218,6 +266,13 @@ open class TextView: UITextView { return } + let finalRange = NSRange(location: selectedRange.location, length: string.length) + let originalText = attributedText.attributedSubstring(from: selectedRange) + + undoManager?.registerUndo(withTarget: self, handler: { [weak self] target in + self?.undoTextReplacement(of: originalText, finalRange: finalRange) + }) + string.loadLazyAttachments() storage.replaceCharacters(in: selectedRange, with: string) @@ -240,23 +295,95 @@ open class TextView: UITextView { selectedRange = NSRange(location: selectedRange.location + string.length, length: 0) } + // MARK: - Intercept Keystrokes + + override open var keyCommands: [UIKeyCommand]? { + get { + // When the keyboard "enter" key is pressed, the keycode corresponds to .carriageReturn, + // even if it's later converted to .lineFeed by default. + // + return [ + UIKeyCommand(input: String(.carriageReturn), modifierFlags: .shift, action: #selector(handleShiftEnter(command:))), + UIKeyCommand(input: String(.tab), modifierFlags: .shift, action: #selector(handleShiftTab(command:))), + UIKeyCommand(input: String(.tab), modifierFlags: [], action: #selector(handleTab(command:))) + ] + } + } + + func handleShiftEnter(command: UIKeyCommand) { + insertText(String(.lineSeparator)) + } + + func handleShiftTab(command: UIKeyCommand) { + guard let list = TextListFormatter.lists(in: typingAttributes).last else { + return + } + + let formatter = TextListFormatter(style: list.style, placeholderAttributes: nil, increaseDepth: true) + let targetRange = formatter.applicationRange(for: selectedRange, in: storage) + + performUndoable(at: targetRange) { + let finalRange = formatter.removeAttributes(from: storage, at: targetRange) + typingAttributes = textStorage.attributes(at: targetRange.location, effectiveRange: nil) + return finalRange + } + } + + func handleTab(command: UIKeyCommand) { + let lists = TextListFormatter.lists(in: typingAttributes) + guard let list = lists.last, lists.count < maximumListIndentationLevels else { + insertText(String(.tab)) + return + } + + let formatter = TextListFormatter(style: list.style, placeholderAttributes: nil, increaseDepth: true) + let targetRange = formatter.applicationRange(for: selectedRange, in: storage) + + performUndoable(at: targetRange) { + let finalRange = formatter.applyAttributes(to: storage, at: targetRange) + typingAttributes = textStorage.attributes(at: targetRange.location, effectiveRange: nil) + return finalRange + } + } + + + // MARK: - Pasteboard Helpers + + private func storeInPasteboard(encoded data: Data) { + let pasteboard = UIPasteboard.general + pasteboard.items[0][NSAttributedString.pastesboardUTI] = data + } // MARK: - Intercept keyboard operations open override func insertText(_ text: String) { - // Note: - // Whenever the entered text causes the Paragraph Attributes to be removed, we should prevent the actual - // text insertion to happen. Thus, we won't call super.insertText. - // But because we don't call the super we need to refresh the attributes ourselfs, and callback to the delegate. - if ensureRemovalOfParagraphAttributes(insertedText: text, at: selectedRange) { - if self.textStorage.length > 0 { - typingAttributes = textStorage.attributes(at: min(selectedRange.location, textStorage.length-1), effectiveRange: nil) - } - delegate?.textViewDidChangeSelection?(self) - delegate?.textViewDidChange?(self) + + // For some reason the text view is allowing the attachment style to be set in + // typingAttributes. That's simply not acceptable. + // + // This was causing the following issue: + // https://github.com/wordpress-mobile/AztecEditor-iOS/issues/462 + // + typingAttributes[NSAttachmentAttributeName] = nil + + // For some reason the text view is allowing the attachment style to be set in + // typingAttributes. That's simply not acceptable. + // + // This was causing the following issue: + // https://github.com/wordpress-mobile/AztecEditor-iOS/issues/462 + // + typingAttributes[NSAttachmentAttributeName] = nil + + guard !ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: text) else { return } + /// Whenever the user is at the end of the document, while editing a [List, Blockquote, Pre], we'll need + /// to insert a `\n` character, so that the Layout Manager immediately renders the List's new bullet + /// (or Blockquote's BG). + /// + ensureInsertionOfEndOfLine(beforeInserting: text) + // Emoji Fix: // Fallback to the default font, whenever the Active Font's Family doesn't match with the Default Font's family. // We do this twice (before and after inserting text), in order to properly handle two scenarios: @@ -274,9 +401,9 @@ open class TextView: UITextView { super.insertText(text) - restoreDefaultFontIfNeeded() + ensureRemovalOfSingleLineParagraphAttributesAfterPressingEnter(input: text) - ensureRemovalOfSingleLineParagraphAttributes(insertedText: text, at: selectedRange) + restoreDefaultFontIfNeeded() ensureCursorRedraw(afterEditing: text) } @@ -292,13 +419,11 @@ open class TextView: UITextView { deletedString = storage.attributedSubstring(from: deletionRange) } - super.deleteBackward() + ensureRemovalOfParagraphStylesBeforeRemovingCharacter(at: selectedRange) - if storage.string.isEmpty { - return - } + super.deleteBackward() - refreshStylesAfterDeletion(of: deletedString, at: deletionRange) + ensureRemovalOfParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() ensureCursorRedraw(afterEditing: deletedString.string) delegate?.textViewDidChange?(self) } @@ -331,10 +456,12 @@ open class TextView: UITextView { /// Converts the current Attributed Text into a raw HTML String /// + /// - Parameter prettyPrint: Indicates if the output HTML should be pretty printed, or not + /// /// - Returns: The HTML version of the current Attributed String. /// - open func getHTML() -> String { - return storage.getHTML() + open func getHTML(prettyPrint: Bool = false) -> String { + return storage.getHTML(prettyPrint: prettyPrint) } @@ -361,6 +488,12 @@ open class TextView: UITextView { } + // MARK: - Attachment Helpers + + open func registerAttachmentImageProvider(_ provider: TextViewAttachmentImageProvider) { + textAttachmentImageProvider.append(provider) + } + // MARK: - Getting format identifiers private let formatterIdentifiersMap: [FormattingIdentifier: AttributeFormatter] = [ @@ -378,6 +511,7 @@ open class TextView: UITextView { .header4: HeaderFormatter(headerLevel: .h4, placeholderAttributes: nil), .header5: HeaderFormatter(headerLevel: .h5, placeholderAttributes: nil), .header6: HeaderFormatter(headerLevel: .h6, placeholderAttributes: nil), + .p: HTMLParagraphFormatter() ] /// Get a list of format identifiers spanning the specified range as a String array. @@ -450,24 +584,45 @@ open class TextView: UITextView { // MARK: - UIResponderStandardEditActions open override func toggleBoldface(_ sender: Any?) { - super.toggleBoldface(sender) + // We need to make sure our formatter is called. We can't go ahead with the default + // implementation. + // + toggleBold(range: selectedRange) + formattingDelegate?.textViewCommandToggledAStyle() } open override func toggleItalics(_ sender: Any?) { - super.toggleItalics(sender) + // We need to make sure our formatter is called. We can't go ahead with the default + // implementation. + // + toggleItalic(range: selectedRange) + formattingDelegate?.textViewCommandToggledAStyle() } open override func toggleUnderline(_ sender: Any?) { - super.toggleUnderline(sender) + // We need to make sure our formatter is called. We can't go ahead with the default + // implementation. + // + toggleUnderline(range: selectedRange) + formattingDelegate?.textViewCommandToggledAStyle() } // MARK: - Formatting func toggle(formatter: AttributeFormatter, atRange range: NSRange) { - let applicationRange = storage.toggle(formatter: formatter, at: range) + + let applicationRange = formatter.applicationRange(for: range, in: textStorage) + let originalString = storage.attributedSubstring(from: applicationRange) + + storage.toggle(formatter: formatter, at: range) + + undoManager?.registerUndo(withTarget: self, handler: { [weak self] target in + self?.undoTextReplacement(of: originalString, finalRange: applicationRange) + }) + if applicationRange.length == 0 { typingAttributes = formatter.toggle(in: typingAttributes) } else { @@ -519,6 +674,23 @@ open class TextView: UITextView { toggle(formatter: formatter, atRange: range) } + /// Adds or removes a Pre style from the specified range. + /// Pre are applied to an entire paragrah regardless of the range. + /// If the range spans multiple paragraphs, the style is applied to all + /// affected paragraphs. + /// + /// - Parameters: + /// - range: The NSRange to edit. + /// + open func togglePre(range: NSRange) { + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile(forApplicationRange: range) + + let formatter = PreFormatter(placeholderAttributes: typingAttributes) + toggle(formatter: formatter, atRange: range) + + forceRedrawCursorAfterDelay() + } + /// Adds or removes a blockquote style from the specified range. /// Blockquotes are applied to an entire paragrah regardless of the range. /// If the range spans multiple paragraphs, the style is applied to all @@ -528,8 +700,11 @@ open class TextView: UITextView { /// - range: The NSRange to edit. /// open func toggleBlockquote(range: NSRange) { + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile(forApplicationRange: range) + let formatter = BlockquoteFormatter(placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) + forceRedrawCursorAfterDelay() } @@ -538,8 +713,11 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleOrderedList(range: NSRange) { + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile(forApplicationRange: range) + let formatter = TextListFormatter(style: .ordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) + forceRedrawCursorAfterDelay() } @@ -549,8 +727,11 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleUnorderedList(range: NSRange) { + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile(forApplicationRange: range) + let formatter = TextListFormatter(style: .unordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) + forceRedrawCursorAfterDelay() } @@ -559,28 +740,29 @@ open class TextView: UITextView { /// /// - Parameter range: The NSRange to edit. /// - open func toggleHeader(_ headerType: HeaderFormatter.HeaderType, range: NSRange) { + open func toggleHeader(_ headerType: Header.HeaderType, range: NSRange) { let formatter = HeaderFormatter(headerLevel: headerType, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) forceRedrawCursorAfterDelay() } - /// Inserts an horizontal ruler on the specified range + /// Replaces with an horizontal ruler on the specified range /// /// - Parameter range: the range where the ruler will be inserted /// - open func replaceRangeWithHorizontalRuler(_ range: NSRange) { - storage.replaceRangeWithHorizontalRuler(range) - let length = NSAttributedString(attachment:NSTextAttachment()).length - textStorage.addAttributes(typingAttributes, range: NSMakeRange(range.location, length)) - selectedRange = NSMakeRange(range.location + length, 0) - delegate?.textViewDidChange?(self) + open func replaceWithHorizontalRuler(at range: NSRange) { + let line = LineAttachment() + replace(at: range, with: line) } + fileprivate lazy var defaultAttributes: [String: Any] = { + return [ + NSFontAttributeName: self.defaultFont, + NSParagraphStyleAttributeName: ParagraphStyle.default + ] + }() - private let paragraphFormatters: [AttributeFormatter] = [ - TextListFormatter(style: .ordered), - TextListFormatter(style: .unordered), + private lazy var paragraphFormatters: [AttributeFormatter] = [ BlockquoteFormatter(), HeaderFormatter(headerLevel:.h1), HeaderFormatter(headerLevel:.h2), @@ -588,8 +770,9 @@ open class TextView: UITextView { HeaderFormatter(headerLevel:.h4), HeaderFormatter(headerLevel:.h5), HeaderFormatter(headerLevel:.h6), + PreFormatter(placeholderAttributes: self.defaultAttributes) ] - + /// After text deletion, this helper will re-apply the Text Formatters at the specified range, if they were /// present in the segment previous to the modified range. /// @@ -598,17 +781,16 @@ open class TextView: UITextView { /// - range: Position in which the deletedText was present in the storage. /// private func refreshStylesAfterDeletion(of deletedText: NSAttributedString, at range: NSRange) { - guard deletedText.string == String(.newline) || range.location == 0 else { + guard deletedText.string.isEndOfLine() || range.location == 0 else { return } + for formatter in paragraphFormatters { if let locationBefore = storage.string.location(before: range.location), formatter.present(in: textStorage, at: locationBefore) { if range.endLocation < storage.length { formatter.applyAttributes(to: storage, at: range) } - } else if formatter.present(in: textStorage, at: range.location) || range.location == 0 { - formatter.removeAttributes(from: textStorage, at: range) } } } @@ -627,93 +809,76 @@ open class TextView: UITextView { } - /// Indicates whether ParagraphStyles should be removed, when inserting the specified string, at a given location, - /// or not. Note that we should remove Paragraph Styles whenever: + /// Inserts an end-of-line character whenever the provided range is at end-of-file, in an + /// empty paragraph. This is useful when attempting to apply a paragraph-level style at EOF, + /// since it won't be possible without the paragraph having any characters. /// - /// - The previous string contains just a newline - /// - The next string is a newline (or we're at the end of the text storage) - /// - We're at the beginning of a new line - /// - The user just typed a new line + /// Call this method before applying the formatter. /// /// - Parameters: - /// - insertedText: String that was just inserted - /// - at: Location in which the string was just inserted + /// - range: the range where the formatter will be applied. /// - /// - Returns: True if we should remove the paragraph attributes. False otherwise! - /// - private func shouldRemoveParagraphAttributes(insertedText text: String, at location: Int) -> Bool { - guard text == String(.newline) else { - return false - } + private func ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile(forApplicationRange range: NSRange) { - let afterRange = NSRange(location: location, length: 1) - let beforeRange = NSRange(location: location - 1, length: 1) - - var afterString = String(.newline) - var beforeString = String(.newline) - if beforeRange.location >= 0 { - beforeString = storage.attributedSubstring(from: beforeRange).string + guard let selectedRangeForSwift = textStorage.string.nsRange(fromUTF16NSRange: range) else { + assertionFailure("This should never happen. Review the logic!") + return } - if afterRange.endLocation < storage.length { - afterString = storage.attributedSubstring(from: afterRange).string - } + if selectedRangeForSwift.location == textStorage.length + && textStorage.string.isEmptyParagraph(at: selectedRangeForSwift.location) { - return beforeString == String(.newline) && afterString == String(.newline) && storage.isStartOfNewLine(atLocation: location) + insertEndOfLineCharacter() + } } - - /// This helper will proceed to remove the Paragraph attributes, in a given string, at the specified range, - /// if needed (please, check `shouldRemoveParagraphAttributes` to learn the conditions that would trigger this!). + /// Inserts an end-of-line chracter whenever: /// - /// - Parameters: - /// - insertedText: String that just got inserted. - /// - at: Range in which the string was inserted. + /// A. We're about to insert a new line + /// B. We're at the end of the document + /// C. There's a List (OR) Blockquote (OR) Pre active /// - /// - Returns: True if ParagraphAttributes were removed. False otherwise! + /// We're doing this as a workaround, in order to force the LayoutManager render the Bullet (OR) + /// Blockquote's background. /// - func ensureRemovalOfParagraphAttributes(insertedText text: String, at range: NSRange) -> Bool { + private func ensureInsertionOfEndOfLine(beforeInserting text: String) { - guard shouldRemoveParagraphAttributes(insertedText: text, at: range.location) else { - return false + guard text.isEndOfLine(), + selectedRange.location == storage.length else { + return } - let formatters:[AttributeFormatter] = [TextListFormatter(style: .ordered), TextListFormatter(style: .unordered), BlockquoteFormatter()] - for formatter in formatters { - if formatter.present(in: textStorage, at: range.location) { - formatter.removeAttributes(from: textStorage, at: range) - return true - } + let formatters: [AttributeFormatter] = [ + BlockquoteFormatter(), + PreFormatter(placeholderAttributes: self.defaultAttributes), + TextListFormatter(style: .ordered), + TextListFormatter(style: .unordered) + ] + + let found = formatters.first { formatter in + return formatter.present(in: typingAttributes) } - return false - } + guard found != nil else { + return + } + insertEndOfLineCharacter() + } - /// Indicates whether a new empty paragraph was created after the insertion of text at the specified location - /// - /// - Parameters: - /// - insertedText: String that was just inserted - /// - at: Location in which the string was just inserted + /// Inserts a end-of-line character at the current position, while retaining the selectedRange + /// and typingAttributes. /// - /// - Returns: True if we should remove the paragraph attributes. False otherwise! - /// - private func isNewEmptyParagraphAfter(insertedText text: String, at location: Int) -> Bool { - guard text == String(.newline) else { - return false - } + private func insertEndOfLineCharacter() { + let previousRange = selectedRange + let previousStyle = typingAttributes - let afterRange = NSRange(location: location, length: 1) - var afterString = String(.newline) - - if afterRange.endLocation < storage.length { - afterString = storage.attributedSubstring(from: afterRange).string - } + super.insertText(String(.paragraphSeparator)) - return afterString == String(.newline) && storage.isStartOfNewLine(atLocation: location) + selectedRange = previousRange + typingAttributes = previousStyle } - /// Upon Text Insertion, we'll remove the NSLinkAttribute whenever the new text **IS NOT** surrounded by /// the NSLinkAttribute. Meaning that: /// @@ -738,45 +903,11 @@ open class TextView: UITextView { } - private let formattersThatBreakAfterEnter: [AttributeFormatter] = [ - HeaderFormatter(headerLevel:.h1), - HeaderFormatter(headerLevel:.h2), - HeaderFormatter(headerLevel:.h3), - HeaderFormatter(headerLevel:.h4), - HeaderFormatter(headerLevel:.h5), - HeaderFormatter(headerLevel:.h6), - ] - /// This helper will proceed to remove the Paragraph attributes when a new line is inserted at the end of an paragraph. - /// Examples of this are the header attributes (Heading 1 to 6) When you start a new paragraph it shoudl reset to the standard style. - /// - /// - Parameters: - /// - insertedText: String that just got inserted. - /// - at: Range in which the string was inserted. - /// - /// - Returns: True if ParagraphAttributes were removed. False otherwise! - /// - @discardableResult func ensureRemovalOfSingleLineParagraphAttributes(insertedText text: String, at range: NSRange) -> Bool { - - guard isNewEmptyParagraphAfter(insertedText: text, at: range.location) else { - return false - } - - for formatter in formattersThatBreakAfterEnter { - if formatter.present(in: textStorage, at: range.location) { - formatter.removeAttributes(from: textStorage, at: range) - return true - } - } - - return false - } - - /// Force the SDK to Redraw the cursor, asynchronously, if the edited text (inserted / deleted) requires it. /// This method was meant as a workaround for Issue #144. /// func ensureCursorRedraw(afterEditing text: String) { - guard text == String(.newline) else { + guard text == String(.lineFeed) else { return } @@ -792,8 +923,10 @@ open class TextView: UITextView { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { let pristine = self.selectedRange let updated = NSMakeRange(max(pristine.location - 1, 0), 0) + let beforeTypingAttributes = self.typingAttributes self.selectedRange = updated self.selectedRange = pristine + self.typingAttributes = beforeTypingAttributes } } @@ -808,14 +941,23 @@ open class TextView: UITextView { /// - range: The NSRange to edit. /// open func setLink(_ url: URL, title: String, inRange range: NSRange) { + + let originalText = attributedText.attributedSubstring(from: range) + let attributedTitle = NSAttributedString(string: title) + let finalRange = NSRange(location: range.location, length: attributedTitle.length) + + undoManager?.registerUndo(withTarget: self, handler: { [weak self] target in + self?.undoTextReplacement(of: originalText, finalRange: finalRange) + }) + let formatter = LinkFormatter() formatter.attributeValue = url let attributes = formatter.apply(to: typingAttributes) - let linkWasPresent = formatter.present(in: storage, at: range) + storage.replaceCharacters(in: range, with: NSAttributedString(string: title, attributes: attributes)) - if range.length == 0 && !linkWasPresent { - selectedRange = NSMakeRange(range.location + (title as NSString).length, 0) - } + + selectedRange = NSRange(location: finalRange.location + finalRange.length, length: 0) + delegate?.textViewDidChange?(self) } @@ -833,72 +975,132 @@ open class TextView: UITextView { // MARK: - Embeds - /// Inserts an image at the specified index + func replace(at range: NSRange, with attachment: NSTextAttachment) { + let originalText = textStorage.attributedSubstring(from: range) + let finalRange = NSRange(location: range.location, length: NSAttributedString.lengthOfTextAttachment) + + undoManager?.registerUndo(withTarget: self, handler: { [weak self] target in + self?.undoTextReplacement(of: originalText, finalRange: finalRange) + }) + let attachmentString = NSAttributedString(attachment: attachment, attributes: typingAttributes) + storage.replaceCharacters(in: range, with: attachmentString) + selectedRange = NSMakeRange(range.location + NSAttributedString.lengthOfTextAttachment, 0) + delegate?.textViewDidChange?(self) + } + + /// Replaces with an image attachment at the specified range /// /// - Parameters: - /// - image: the image object to be inserted. + /// - range: the range where the image will be inserted /// - sourceURL: The url of the image to be inserted. - /// - position: The character index at which to insert the image. + /// - placeHolderImage: the image to be used as an placeholder. + /// - identifier: an unique identifier for the image /// /// - Returns: the attachment object that can be used for further calls /// - open func insertImage(sourceURL url: URL, atPosition position: Int, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> TextAttachment { - let attachment = storage.insertImage(sourceURL: url, atPosition: position, placeHolderImage: placeHolderImage ?? defaultMissingImage, identifier: identifier) - let length = NSAttributedString.lengthOfTextAttachment - textStorage.addAttributes(typingAttributes, range: NSMakeRange(position, length)) - selectedRange = NSMakeRange(position+length, 0) - delegate?.textViewDidChange?(self) + @discardableResult + open func replaceWithImage(at range: NSRange, sourceURL url: URL, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> ImageAttachment { + let attachment = ImageAttachment(identifier: identifier) + attachment.delegate = storage + attachment.url = url + attachment.image = placeHolderImage + replace(at: range, with: attachment) return attachment } - /// Returns the TextAttachment instance with the matching identifier + /// Returns the MediaAttachment instance with the matching identifier /// /// - Parameter id: Identifier of the text attachment to be retrieved /// - open func attachment(withId id: String) -> TextAttachment? { + open func attachment(withId id: String) -> MediaAttachment? { return storage.attachment(withId: id) } - /// Removes the attachments that match the attachament identifier provided from the storage + /// Removes the attachment that matches the attachment identifier provided from the storage /// /// - Parameter attachmentID: the unique id of the attachment /// open func remove(attachmentID: String) { - storage.remove(attachmentID: attachmentID) + guard let range = storage.rangeFor(attachmentID: attachmentID) else { + return + } + let originalText = storage.attributedSubstring(from: range) + let finalRange = NSRange(location: range.location, length: 0) + + undoManager?.registerUndo(withTarget: self, handler: { [weak self] target in + self?.undoTextReplacement(of: originalText, finalRange: finalRange) + }) + + storage.replaceCharacters(in: range, with: NSAttributedString(string: "", attributes: typingAttributes)) delegate?.textViewDidChange?(self) } /// Removes all of the text attachments contained within the storage /// - open func removeTextAttachments() { - storage.removeTextAttachments() + open func removeMediaAttachments() { + storage.removeMediaAttachments() delegate?.textViewDidChange?(self) } - /// Inserts a Video attachment at the specified index + /// Replaces a Video attachment at the specified range /// /// - Parameters: - /// - index: The character index at which to insert the image. - /// - params: TBD + /// - range: the range in the text to insert the video + /// - sourceURL: the video source URL + /// - posterURL: the video poster image URL + /// - placeHolderImage: an image to use has an placeholder while the video poster is being loaded + /// - identifier: an unique indentifier for the video + /// + /// - Returns: the video attachment object that was inserted. /// - open func insertVideo(_ index: Int, params: [String: AnyObject]) { - print("video") + @discardableResult + open func replaceWithVideo(at range: NSRange, sourceURL: URL, posterURL: URL?, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> VideoAttachment { + let attachment = VideoAttachment(identifier: identifier, srcURL: sourceURL, posterURL: posterURL) + attachment.delegate = storage + attachment.image = placeHolderImage + replace(at: range, with: attachment) + return attachment } - /// Returns the associated TextAttachment, at a given point, if any. + /// Returns the associated NSTextAttachment, at a given point, if any. /// /// - Parameter point: The point on screen to check for attachments. /// - /// - Returns: The associated TextAttachment. + /// - Returns: The associated NSTextAttachment. /// - open func attachmentAtPoint(_ point: CGPoint) -> TextAttachment? { + open func attachmentAtPoint(_ point: CGPoint) -> NSTextAttachment? { let index = layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) guard index < textStorage.length else { return nil } - return textStorage.attribute(NSAttachmentAttributeName, at: index, effectiveRange: nil) as? TextAttachment + var effectiveRange = NSRange() + guard let attachment = textStorage.attribute(NSAttachmentAttributeName, at: index, effectiveRange: &effectiveRange) as? NSTextAttachment else { + return nil + } + + var bounds = layoutManager.boundingRect(forGlyphRange: effectiveRange, in: textContainer) + bounds.origin.x += textContainerInset.left + bounds.origin.y += textContainerInset.top + + // Let's check if we have media attachment in place + var mediaBounds: CGRect = .zero + if let mediaAttachment = attachment as? MediaAttachment { + mediaBounds = mediaAttachment.mediaBounds(forBounds: bounds) + } + + // Correct the bounds taking in account the dimesion of the media image being used + bounds.origin.x += mediaBounds.origin.x + bounds.origin.y += mediaBounds.origin.y + bounds.size.width = mediaBounds.size.width + bounds.size.height = mediaBounds.size.height + + if bounds.contains(point) { + return attachment + } + + return nil } /// Move the selected range to the nearest character of the point specified in the textView @@ -930,7 +1132,7 @@ open class TextView: UITextView { open func isPointInsideAttachmentMargin(point: CGPoint) -> Bool { let index = layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - if let attachment = attachmentAtPoint(point) { + if let attachment = attachmentAtPoint(point) as? MediaAttachment { let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: index, length: 1), actualCharacterRange: nil) let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) if point.y >= rect.origin.y && point.y <= (rect.origin.y + (2*attachment.imageMargin)) { @@ -1019,17 +1221,37 @@ open class TextView: UITextView { /// Updates the attachment properties to the new values /// - /// - parameter attachment: the attachment to update - /// - parameter alignment: the alignment value - /// - parameter size: the size value - /// - parameter url: the attachment url - /// - open func update(attachment: TextAttachment, - alignment: TextAttachment.Alignment, - size: TextAttachment.Size, + /// - Parameters: + /// - attachment: the attachment to update + /// - alignment: the alignment value + /// - size: the size value + /// - url: the attachment url + /// + open func update(attachment: ImageAttachment, + alignment: ImageAttachment.Alignment, + size: ImageAttachment.Size, url: URL) { storage.update(attachment: attachment, alignment: alignment, size: size, url: url) - layoutManager.invalidateLayoutForAttachment(attachment) + layoutManager.invalidateLayout(for: attachment) + layoutManager.ensureLayoutForContainers() + delegate?.textViewDidChange?(self) + } + + open func update(attachment: VideoAttachment) { + layoutManager.invalidateLayout(for: attachment) + layoutManager.ensureLayoutForContainers() + delegate?.textViewDidChange?(self) + } + + + /// Updates the Attachment's HTML contents to the new specified value. + /// + /// - Parameters: + /// - attachment: The attachment to be updated + /// - html: New *VALID* HTML to be set + /// + open func update(attachment: HTMLAttachment, html: String) { + storage.update(attachment: attachment, html: html) delegate?.textViewDidChange?(self) } @@ -1038,14 +1260,15 @@ open class TextView: UITextView { /// - Parameters: /// - attachment: the attachment to update /// - open func refreshLayoutFor(attachment: TextAttachment) { - layoutManager.invalidateLayoutForAttachment(attachment) + open func refreshLayout(for attachment: MediaAttachment) { + layoutManager.invalidateLayout(for: attachment) + layoutManager.ensureLayoutForContainers() } // MARK: - More - /// Inserts an HTML Comment at the specified position. + /// Replaces with an HTML Comment at the specified range. /// /// - Parameters: /// - range: The character range that must be replaced with a Comment Attachment. @@ -1054,75 +1277,279 @@ open class TextView: UITextView { /// - Returns: the attachment object that can be used for further calls /// @discardableResult - open func replaceRangeWithCommentAttachment(_ range: NSRange, text: String) -> CommentAttachment { - let attachment = storage.replaceRangeWithCommentAttachment(range, text: text, attributes: typingAttributes) - - selectedRange = NSMakeRange(range.location + NSAttributedString.lengthOfTextAttachment, 0) - delegate?.textViewDidChange?(self) + open func replaceWithComment(at range: NSRange, text: String) -> CommentAttachment { + let attachment = CommentAttachment() + attachment.text = text + replace(at: range, with: attachment) return attachment } } -// MARK: - TextStorageImageProvider +// MARK: - Single line attributes removal + +private extension TextView { + + // MARK: - WORKAROUND: Removing paragraph styles after deleting the last character in the current line. + + /// Ensures Paragraph Styles are removed, if needed, *before* a character gets removed from the storage, + /// at the specified range. + /// + /// - Parameter range: Range at which a character is about to be removed. + /// + func ensureRemovalOfParagraphStylesBeforeRemovingCharacter(at range: NSRange) { + guard mustRemoveParagraphStylesBeforeRemovingCharacter(at: range) else { + return + } + + removeParagraphAttributes(at: range) + } + + /// Analyzes whether the attributes should be removed from the specified location, *before* removing a + /// character at the specified location. + /// + /// - Parameter range: Range at which we'll remove a character + /// + /// - Returns: `true` if we should nuke the paragraph attributes. + /// + private func mustRemoveParagraphStylesBeforeRemovingCharacter(at range: NSRange) -> Bool { + return storage.string.isEmptyParagraph(at: range.location) + } + + /// Removes paragraph attributes after a selection change. The logic that defines if the + /// attributes must be removed is located in + /// `mustRemoveSingleLineParagraphAttributes()`. + /// + /// - Parameters: + /// - text: the text that was just inserted into the TextView. + /// + func ensureRemovalOfSingleLineParagraphAttributesAfterPressingEnter(input: String) { + guard mustRemoveSingleLineParagraphAttributesAfterPressingEnter(input: input) else { + return + } + removeSingleLineParagraphAttributes(at: selectedRange) + } + + /// Analyzes whether paragraph attributes should be removed from the specified + /// location, or not, after the selection range is changed. + /// + /// - Parameters: + /// - text: the text that was just inserted into the TextView. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveSingleLineParagraphAttributesAfterPressingEnter(input: String) -> Bool { + return input.isEndOfLine() && storage.string.isEmptyParagraph(at: selectedRange.location) + } + + + /// Removes the Paragraph Attributes [Blockquote, Pre, Lists] at the specified range. If the range + /// is beyond the storage's contents, the typingAttributes will be modified + /// + func removeSingleLineParagraphAttributes(at range: NSRange) { + + let formatters: [AttributeFormatter] = [ + HeaderFormatter(headerLevel: .h1, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h2, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h3, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h4, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h5, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h6, placeholderAttributes: [:]) + ] + + for formatter in formatters where formatter.present(in: typingAttributes) { + typingAttributes = formatter.remove(from: typingAttributes) + + let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) + formatter.removeAttributes(from: textStorage, at: applicationRange) + } + } + + // MARK: - Remove paragraph styles when pressing enter in an empty paragraph + + /// Removes paragraph attributes after pressing enter in an empty paragraph. + /// + /// - Parameters: + /// - input: the user's input. This method must be called before the input is processed. + /// + func ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + guard mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: input) else { + return false + } + + removeParagraphAttributes(at: selectedRange) + + return true + } + + /// Analyzes whether paragraph attributes should be removed after pressing enter in an empty + /// paragraph. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + return input.isEndOfLine() + && storage.string.isEmptyParagraph(at: selectedRange.location) + && (BlockquoteFormatter().present(in: typingAttributes) + || TextListFormatter.listsOfAnyKindPresent(in: typingAttributes) + || PreFormatter().present(in: typingAttributes)) + } + + /// Removes the Paragraph Attributes [Blockquote, Pre, Lists] at the specified range. If the range + /// is beyond the storage's contents, the typingAttributes will be modified + /// + func removeTextListAttributes(at range: NSRange) { + let formatters: [AttributeFormatter] = [ + BlockquoteFormatter(), + PreFormatter(), + TextListFormatter(style: .ordered), + TextListFormatter(style: .unordered) + ] + + for formatter in formatters where formatter.present(in: super.typingAttributes) { + typingAttributes = formatter.remove(from: typingAttributes) + + let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) + formatter.removeAttributes(from: textStorage, at: applicationRange) + } + } + + // MARK: - Remove paragraph styles when pressing backspace and removing the last character + + /// Removes paragraph attributes after pressing backspace, if the resulting document is empty. + /// + func ensureRemovalOfParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() { + guard mustRemoveParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() else { + return + } + + removeParagraphAttributes(at: selectedRange) + } + + /// Analyzes whether paragraph attributes should be removed from the specified + /// location, or not, after pressing backspace. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() -> Bool { + return storage.length == 0 + } + + // MARK: - WORKAROUND: removing styles at EOF due to selection change + + /// Removes paragraph attributes after a selection change. + /// + func ensureRemovalOfParagraphAttributesAfterSelectionChange() { + guard mustRemoveParagraphAttributesAfterSelectionChange() else { + return + } + + removeParagraphAttributes(at: selectedRange) + } + + /// Analyzes whether paragraph attributes should be removed from the specified + /// location, or not, after the selection range is changed. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveParagraphAttributesAfterSelectionChange() -> Bool { + return selectedRange.location == storage.length + && storage.string.isEmptyParagraph(at: selectedRange.location) + } + + + /// Removes the Paragraph Attributes [Blockquote, Pre, Lists] at the specified range. If the range + /// is beyond the storage's contents, the typingAttributes will be modified + /// + func removeParagraphAttributes(at range: NSRange) { + let formatters: [AttributeFormatter] = [ + BlockquoteFormatter(), + PreFormatter(placeholderAttributes: defaultAttributes), + TextListFormatter(style: .ordered), + TextListFormatter(style: .unordered) + ] + + for formatter in formatters where formatter.present(in: super.typingAttributes) { + super.typingAttributes = formatter.remove(from: super.typingAttributes) + + let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) + formatter.removeAttributes(from: textStorage, at: applicationRange) + } + } +} + + +// MARK: - TextStorageImageProvider +// extension TextView: TextStorageAttachmentsDelegate { func storage( _ storage: TextStorage, - attachment: TextAttachment, - imageForURL url: URL, + attachment: NSTextAttachment, + imageFor url: URL, onSuccess success: @escaping (UIImage) -> (), onFailure failure: @escaping () -> ()) -> UIImage { - guard let mediaDelegate = mediaDelegate else { - fatalError("This class requires a media delegate to be set.") + guard let textAttachmentDelegate = textAttachmentDelegate else { + fatalError("This class requires a text attachment delegate to be set.") } - let placeholderImage = mediaDelegate.textView(self, imageAtUrl: url, onSuccess: success, onFailure: failure) - return placeholderImage + return textAttachmentDelegate.textView(self, attachment: attachment, imageAt: url, onSuccess: success, onFailure: failure) } - func storage(_ storage: TextStorage, missingImageForAttachment: TextAttachment) -> UIImage { - return defaultMissingImage + func storage(_ storage: TextStorage, missingImageFor attachment: NSTextAttachment) -> UIImage { + guard let textAttachmentDelegate = textAttachmentDelegate else { + fatalError("This class requires a text attachment delegate to be set.") + } + return textAttachmentDelegate.textView(self, placeholderForAttachment: attachment) } - func storage(_ storage: TextStorage, urlForAttachment attachment: TextAttachment) -> URL { - guard let mediaDelegate = mediaDelegate else { - fatalError("This class requires a media delegate to be set.") + func storage(_ storage: TextStorage, urlFor imageAttachment: ImageAttachment) -> URL { + guard let textAttachmentDelegate = textAttachmentDelegate else { + fatalError("This class requires a text attachment delegate to be set.") } - return mediaDelegate.textView(self, urlForAttachment: attachment) + return textAttachmentDelegate.textView(self, urlFor: imageAttachment) } - func storage(_ storage: TextStorage, deletedAttachmentWithID attachmentID: String) { - mediaDelegate?.textView(self, deletedAttachmentWithID: attachmentID) + func storage(_ storage: TextStorage, deletedAttachmentWith attachmentID: String) { + textAttachmentDelegate?.textView(self, deletedAttachmentWith: attachmentID) } - func storage(_ storage: TextStorage, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? { - guard let commentsDelegate = commentsDelegate else { - fatalError("This class requires a comments delegate to be set.") + func storage(_ storage: TextStorage, imageFor attachment: NSTextAttachment, with size: CGSize) -> UIImage? { + let provider = textAttachmentImageProvider.first { provider in + return provider.textView(self, shouldRender: attachment) } - return commentsDelegate.textView(self, imageForComment: attachment, with: size) + guard let firstProvider = provider else { + fatalError("This class requires at least one AttachmentImageProvider to be set.") + } + + return firstProvider.textView(self, imageFor: attachment, with: size) } - func storage(_ storage: TextStorage, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect { - guard let commentsDelegate = commentsDelegate else { - fatalError("This class requires a comments delegate to be set.") + func storage(_ storage: TextStorage, boundsFor attachment: NSTextAttachment, with lineFragment: CGRect) -> CGRect { + let provider = textAttachmentImageProvider.first { + $0.textView(self, shouldRender: attachment) } - return commentsDelegate.textView(self, boundsForComment: attachment, with: lineFragment) + guard let firstProvider = provider else { + fatalError("This class requires at least one AttachmentImageProvider to be set.") + } + + return firstProvider.textView(self, boundsFor: attachment, with: lineFragment) } } -// MARK: - UIGestureRecognizerDelegate +// MARK: - UIGestureRecognizerDelegate +// @objc class AttachmentGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate { let textView: TextView - fileprivate var currentSelectedAttachment: TextAttachment? + fileprivate var currentSelectedAttachment: MediaAttachment? public init(textView: TextView) { self.textView = textView @@ -1139,7 +1566,7 @@ extension TextView: TextStorageAttachmentsDelegate { guard textView.attachmentAtPoint(locationInTextView) != nil else { // if we have a current selected attachment let's notify of deselection if let selectedAttachment = currentSelectedAttachment { - textView.mediaDelegate?.textView(textView, deselectedAttachment: selectedAttachment, atPosition: locationInTextView) + textView.textAttachmentDelegate?.textView(textView, deselected: selectedAttachment, atPosition: locationInTextView) } currentSelectedAttachment = nil return false @@ -1163,14 +1590,61 @@ extension TextView: TextStorageAttachmentsDelegate { if textView.isPointInsideAttachmentMargin(point: locationInTextView) { if let selectedAttachment = currentSelectedAttachment { - textView.mediaDelegate?.textView(textView, deselectedAttachment: selectedAttachment, atPosition: locationInTextView) + textView.textAttachmentDelegate?.textView(textView, deselected: selectedAttachment, atPosition: locationInTextView) } currentSelectedAttachment = nil return } - currentSelectedAttachment = attachment - textView.mediaDelegate?.textView(textView, selectedAttachment: attachment, atPosition: locationInTextView) + currentSelectedAttachment = attachment as? MediaAttachment + textView.textAttachmentDelegate?.textView(textView, selected: attachment, atPosition: locationInTextView) + } +} + + +// MARK: - Undo implementation + +private extension TextView { + + /// Undoable Operation. Returns the Final Text Range, resulting from applying the undoable Operation + /// Note that for Styling Operations, the Final Range will most likely match the Initial Range. + /// For text editing it will only match the initial range if the original string was replaced with a + /// string of the same length. + /// + typealias Undoable = () -> NSRange + + + /// Registers an Undoable Operation, which will be applied at the specified Initial Range. + /// + /// - Parameters: + /// - initialRange: Initial Storage Range upon which we'll apply a transformation. + /// - block: Undoable Operation. Should return the resulting Substring's Range. + /// + func performUndoable(at initialRange: NSRange, block: Undoable) { + let originalString = storage.attributedSubstring(from: initialRange) + + let finalRange = block() + + undoManager?.registerUndo(withTarget: self, handler: { [weak self] target in + self?.undoTextReplacement(of: originalString, finalRange: finalRange) + }) + + delegate?.textViewDidChange?(self) + } + + func undoTextReplacement(of originalText: NSAttributedString, finalRange: NSRange) { + + let redoFinalRange = NSRange(location: finalRange.location, length: originalText.length) + let redoOriginalText = storage.attributedSubstring(from: finalRange) + + storage.replaceCharacters(in: finalRange, with: originalText) + selectedRange = redoFinalRange + + undoManager?.registerUndo(withTarget: self, handler: { [weak self] target in + self?.undoTextReplacement(of: redoOriginalText, finalRange: redoFinalRange) + }) + + delegate?.textViewDidChange?(self) } } diff --git a/Aztec/Classes/TextKit/VideoAttachment.swift b/Aztec/Classes/TextKit/VideoAttachment.swift new file mode 100644 index 000000000..e95a91968 --- /dev/null +++ b/Aztec/Classes/TextKit/VideoAttachment.swift @@ -0,0 +1,112 @@ +import Foundation +import UIKit + +protocol VideoAttachmentDelegate: class { + func videoAttachment( + _ videoAttachment: VideoAttachment, + imageForURL url: URL, + onSuccess success: @escaping (UIImage) -> (), + onFailure failure: @escaping () -> ()) -> UIImage +} + +/// Custom text attachment. +/// +open class VideoAttachment: MediaAttachment { + + /// Attachment video URL + /// + open var srcURL: URL? + + /// Video poster image to show, while the video is not played. + /// + open var posterURL: URL? { + get { + return self.url + } + + set { + self.url = newValue + } + } + + /// Attributes accessible by the user, for general purposes. + /// + open var namedAttributes = [String: String]() + + /// Creates a new attachment + /// + /// - parameter identifier: An unique identifier for the attachment + /// + required public init(identifier: String, srcURL: URL? = nil, posterURL: URL? = nil) { + self.srcURL = srcURL + + super.init(identifier: identifier, url: posterURL) + + self.overlayImage = Assets.playIcon + } + + /// Required Initializer + /// + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + if aDecoder.containsValue(forKey: EncodeKeys.srcURL.rawValue) { + srcURL = aDecoder.decodeObject(forKey: EncodeKeys.srcURL.rawValue) as? URL + } + } + + /// Required Initializer + /// + required public init(identifier: String, url: URL?) { + self.srcURL = nil + super.init(identifier: identifier, url: url) + } + + /// Required Initializer + /// + required public init(data contentData: Data?, ofType uti: String?) { + super.init(data: contentData, ofType: uti) + } + + + // MARK: - NSCoder Support + + override open func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + if let url = self.srcURL { + aCoder.encode(url, forKey: EncodeKeys.srcURL.rawValue) + } + } + + fileprivate enum EncodeKeys: String { + case srcURL + } + + + // MARK: - Origin calculation + + override func xPosition(forContainerWidth containerWidth: CGFloat) -> CGFloat { + let imageWidth = onScreenWidth(containerWidth) + + return CGFloat(floor((containerWidth - imageWidth) / 2)) + } + + override func onScreenHeight(_ containerWidth: CGFloat) -> CGFloat { + if let image = image { + let targetWidth = onScreenWidth(containerWidth) + let scale = targetWidth / image.size.width + + return floor(image.size.height * scale) + (imageMargin * 2) + } else { + return 0 + } + } + + override func onScreenWidth(_ containerWidth: CGFloat) -> CGFloat { + if let image = image { + return floor(min(image.size.width, containerWidth)) + } else { + return 0 + } + } +} diff --git a/AztecTests/Converters/HTMLNodeToNSAttributedStringTests.swift b/AztecTests/Converters/HTMLNodeToNSAttributedStringTests.swift new file mode 100644 index 000000000..e7e1d82b5 --- /dev/null +++ b/AztecTests/Converters/HTMLNodeToNSAttributedStringTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import Aztec + + +// MARK: - HTMLNodeToNSAttributedStringTests +// +class HTMLNodeToNSAttributedStringTests: XCTestCase { + + /// Typealiases + /// + typealias ElementNode = Libxml2.ElementNode + typealias Node = Libxml2.Node + typealias RootNode = Libxml2.RootNode + typealias StringAttribute = Libxml2.StringAttribute + typealias TextNode = Libxml2.TextNode + typealias CommentNode = Libxml2.CommentNode + + + /// Verifies that Nodes are preserved into the NSAttributedString instance, by means of the UnsupportedHTML + /// attribute. + /// + func testMultipleSpanNodesAreProperlyPreservedWithinUnsupportedHtmlAttribute() { + let textNode = TextNode(text: "Ehlo World!") + + // + let spanAttribute2 = StringAttribute(name: "class", value: "aztec") + let spanNode2 = ElementNode(type: .span, attributes: [spanAttribute2], children: [textNode]) + + // + let spanAttribute1 = StringAttribute(name: "class", value: "first") + let spanNode1 = ElementNode(type: .span, attributes: [spanAttribute1], children: [spanNode2]) + + //

+ let headerNode = ElementNode(type: .h1, attributes: [], children: [spanNode1]) + let rootNode = RootNode(children: [headerNode]) + + // Convert + Test + let output = attributedString(from: rootNode) + + var range = NSRange() + guard let unsupportedHTML = output.attribute(UnsupportedHTMLAttributeName, at: 0, effectiveRange: &range) as? UnsupportedHTML else { + XCTFail() + return + } + + XCTAssert(range.length == textNode.length()) + XCTAssert(unsupportedHTML.elements.count == 2) + + let restoredSpanElement2 = unsupportedHTML.elements.last + XCTAssertEqual(restoredSpanElement2?.name, "span") + + let restoredSpanAttribute2 = restoredSpanElement2?.attributes.first + XCTAssertEqual(restoredSpanAttribute2?.name, "class") + XCTAssertEqual(restoredSpanAttribute2?.value, "aztec") + + let restoredSpanElement1 = unsupportedHTML.elements.first + XCTAssertEqual(restoredSpanElement1?.name, "span") + + let restoredSpanAttribute1 = restoredSpanElement1?.attributes.first + XCTAssertEqual(restoredSpanAttribute1?.name, "class") + XCTAssertEqual(restoredSpanAttribute1?.value, "first") + } +} + + +// MARK: - Helpers +// +extension HTMLNodeToNSAttributedStringTests { + + func attributedString(from node: Node) -> NSAttributedString { + let descriptor = UIFont.boldSystemFont(ofSize: 14).fontDescriptor + let converter = HTMLNodeToNSAttributedString(usingDefaultFontDescriptor: descriptor) + + return converter.convert(node) + } +} diff --git a/AztecTests/Converters/NSAttributedStringToNodesTests.swift b/AztecTests/Converters/NSAttributedStringToNodesTests.swift new file mode 100644 index 000000000..cc34d1324 --- /dev/null +++ b/AztecTests/Converters/NSAttributedStringToNodesTests.swift @@ -0,0 +1,736 @@ +import XCTest +@testable import Aztec + + +// MARK: - NSAttributedStringToNodesTests +// +class NSAttributedStringToNodesTests: XCTestCase { + + /// Verifies that Bold Style gets effectively mapped. + /// + /// - Input: [Bold Style]Bold?[/Bold Style] + /// + /// - Output: Bold? + /// + func testBoldStyleEffectivelyMapsIntoItsTreeRepresentation() { + let attributes = BoldFormatter().apply(to: Constants.sampleAttributes) + let string = NSAttributedString(string: "Bold?", attributes: attributes) + + // Convert + Verify + let node = rootNode(from: string) + XCTAssert(node.children.count == 1) + + let bold = node.children.first as? ElementNode + XCTAssertEqual(bold?.name, "b") + XCTAssert(bold?.children.count == 1) + + let text = bold?.children.first as? TextNode + XCTAssertEqual(text?.contents, string.string) + } + + + /// Verifies that Italics Style gets effectively mapped. + /// + /// - Input: [Italic Style]Italics![/Italic Style] + /// + /// - Output: Italics + /// + func testItalicStyleEffectivelyMapsIntoItsTreeRepresentation() { + let attributes = ItalicFormatter().apply(to: Constants.sampleAttributes) + let string = NSAttributedString(string: "Italics!", attributes: attributes) + + // Convert + Verify + let node = rootNode(from: string) + XCTAssert(node.children.count == 1) + + let italic = node.children.first as? ElementNode + XCTAssertEqual(italic?.name, "i") + XCTAssert(italic?.children.count == 1) + + let text = italic?.children.first as? TextNode + XCTAssertEqual(text?.contents, string.string) + } + + + /// Verifies that Underline Style gets effectively mapped. + /// + /// - Input: [Underline Style]Underlined![/Underline Style] + /// + /// - Output: Underlined! + /// + func testUnderlineStyleEffectivelyMapsIntoItsTreeRepresentation() { + let attributes = UnderlineFormatter().apply(to: Constants.sampleAttributes) + let string = NSAttributedString(string: "Underlined!", attributes: attributes) + + // Convert + Verify + let node = rootNode(from: string) + XCTAssert(node.children.count == 1) + + let underlined = node.children.first as? ElementNode + XCTAssertEqual(underlined?.name, "u") + XCTAssert(underlined?.children.count == 1) + + let text = underlined?.children.first as? TextNode + XCTAssertEqual(text?.contents, string.string) + } + + + /// Verifies that Strike Style gets effectively mapped. + /// + /// - Input: [Strike Style]Strike![/Strike Style] + /// + /// - Output: Strike! + /// + func testStrikeStyleEffectivelyMapsIntoItsTreeRepresentation() { + let attributes = StrikethroughFormatter().apply(to: Constants.sampleAttributes) + let testingString = NSAttributedString(string: "Strike!", attributes: attributes) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 1) + + let strike = node.children.first as? ElementNode + XCTAssertEqual(strike?.name, "strike") + XCTAssert(strike?.children.count == 1) + + let text = strike?.children.first as? TextNode + XCTAssertEqual(text?.contents, testingString.string) + } + + + /// Verifies that Link Style gets effectively mapped. + /// + /// - Input: [Link Style]Yo! Yose! Yosemite![/Link Style] + /// + /// - Output: Yo! Yose! Yosemite! + /// + func testLinkStyleEffectivelyMapsIntoItsTreeRepresentation() { + let formatter = LinkFormatter() + formatter.attributeValue = URL(string: "www.yosemite.com") as Any + + let attributes = formatter.apply(to: Constants.sampleAttributes) + let testingString = NSAttributedString(string: "Yo! Yose! Yosemite!", attributes: attributes) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 1) + + let link = node.children.first as? ElementNode + XCTAssertEqual(link?.name, "a") + XCTAssert(link?.children.count == 1) + + let text = link?.children.first as? TextNode + XCTAssertEqual(text?.contents, testingString.string) + } + + + /// Verifies that Lists get effectively mapped. + /// + /// - Input:
  • First Line\nSecond Line
+ /// + /// - Output:
  • First Line
  • Second Line
+ /// + func testListItemsRemainInTheSameContainingUnorderedList() { + let firstText = "First Line" + let secondText = "Second Line" + + let attributes = TextListFormatter(style: .unordered).apply(to: Constants.sampleAttributes) + + let text = firstText + String(.lineFeed) + secondText + let testingString = NSMutableAttributedString(string: text, attributes: attributes) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 1) + + let list = node.children.first as? ElementNode + XCTAssertEqual(list?.name, "ul") + guard list?.children.count == 2 else { + XCTFail() + return + } + + let firstListItem = list?.children[0] as? ElementNode + let secondListItem = list?.children[1] as? ElementNode + XCTAssertEqual(firstListItem?.name, "li") + XCTAssertEqual(secondListItem?.name, "li") + XCTAssert(firstListItem?.children.count == 1) + XCTAssert(secondListItem?.children.count == 1) + + let firstTextItem = firstListItem?.children.first as? TextNode + let secondTextItem = secondListItem?.children.first as? TextNode + + XCTAssertEqual(firstTextItem?.contents, firstText) + XCTAssertEqual(secondTextItem?.contents, secondText) + } + + + /// Verifies that Lists get effectively mapped. + /// + /// - Input:
  1. First Line\nSecond Line
+ /// + /// - Output:
  1. First Line
  2. Second Line
+ /// + func testListItemsRemainInTheSameContainingOrderedList() { + let firstText = "First Line" + let secondText = "Second Line" + + let attributes = TextListFormatter(style: .ordered).apply(to: Constants.sampleAttributes) + + let text = firstText + String(.lineFeed) + secondText + let testingString = NSMutableAttributedString(string: text, attributes: attributes) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 1) + + let list = node.children.first as? ElementNode + XCTAssertEqual(list?.name, "ol") + guard list?.children.count == 2 else { + XCTFail() + return + } + + let firstListItem = list?.children[0] as? ElementNode + let secondListItem = list?.children[1] as? ElementNode + XCTAssertEqual(firstListItem?.name, "li") + XCTAssertEqual(secondListItem?.name, "li") + XCTAssert(firstListItem?.children.count == 1) + XCTAssert(secondListItem?.children.count == 1) + + let firstTextItem = firstListItem?.children.first as? TextNode + let secondTextItem = secondListItem?.children.first as? TextNode + + XCTAssertEqual(firstTextItem?.contents, firstText) + XCTAssertEqual(secondTextItem?.contents, secondText) + } + + + /// Verifies that Comments get effectively mapped. + /// + /// - Input: [Comment Attachment]I'm a comment. YEAH![Comment Attachment] + /// + /// - Output: I'm a comment. YEAH! + /// + func testCommentsArePreservedAndSerializedBack() { + let attachment = CommentAttachment() + attachment.text = "I'm a comment. YEAH!" + let stringWithAttachment = NSAttributedString(attachment: attachment) + + let text = "Payload here" + let testingString = NSMutableAttributedString(string: text) + testingString.insert(stringWithAttachment, at: 0) + testingString.append(stringWithAttachment) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 3) + + guard let headNode = node.children[0] as? CommentNode, + let textNode = node.children[1] as? TextNode, + let tailNode = node.children[2] as? CommentNode + else { + XCTFail() + return + } + + XCTAssertEqual(headNode.comment, attachment.text) + XCTAssertEqual(tailNode.comment, attachment.text) + XCTAssertEqual(textNode.contents, text) + } + + + /// Verifies that Line Attachments get effectively mapped. + /// + /// - Input: I'm a text line[Line Attachment]I'm a text line[Line Attachment]I'm a text line[Line Attachment] + /// + /// - Output: I'm a text line
I'm a text line
I'm a text line
+ /// + func testLineElementGetsProperlySerialiedBackIntoItsHtmlRepresentation() { + let attachment = LineAttachment() + + let stringWithAttachment = NSAttributedString(attachment: attachment) + let stringWithText = NSAttributedString(string: "I'm a text line") + + let testingString = NSMutableAttributedString() + + testingString.append(stringWithAttachment) + testingString.append(stringWithText) + testingString.append(stringWithAttachment) + testingString.append(stringWithText) + testingString.append(stringWithAttachment) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 5) + + guard let firstLine = node.children[0] as? ElementNode, + let firstText = node.children[1] as? TextNode, + let secondLine = node.children[2] as? ElementNode, + let secondText = node.children[3] as? TextNode, + let thirdLine = node.children[4] as? ElementNode + else { + XCTFail() + return + } + + XCTAssertEqual(firstLine.name, "hr") + XCTAssertEqual(secondLine.name, "hr") + XCTAssertEqual(thirdLine.name, "hr") + XCTAssertEqual(firstText.contents, stringWithText.string) + XCTAssertEqual(secondText.contents, stringWithText.string) + } + + + /// Verifies that Header Style gets properly mapped. + /// + /// - Input:

Aztec Rocks

\nNewline? + /// + /// - Output:

Aztec Rocks

Newline? + /// + func testHeaderElementGetsProperlySerialiedBackIntoItsHtmlRepresentation() { + + let levels: [Header.HeaderType] = [.h1, .h2, .h3, .h4, .h5, .h6] + + for level in levels { + let formatter = HeaderFormatter(headerLevel: level, placeholderAttributes: [:]) + + let headingStyle = formatter.apply(to: Constants.sampleAttributes) + let headingText = NSAttributedString(string: "Aztec Rocks\n", attributes: headingStyle) + let regularText = NSAttributedString(string: "Newline?", attributes: Constants.sampleAttributes) + + let testingString = NSMutableAttributedString() + testingString.append(headingText) + testingString.append(regularText) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 2) + + guard let headerNode = node.children[0] as? ElementNode, + let headerTextNode = headerNode.children[0] as? TextNode, + let regularTextNode = node.children[1] as? TextNode + else { + XCTFail() + return + } + + XCTAssertEqual(headerNode.name, "h\(level.rawValue)") + XCTAssertEqual(headerTextNode.contents, "Aztec Rocks") + XCTAssertEqual(regularTextNode.contents, "Newline?") + } + } + + + /// Verifies that Unknown HTML Attachments get properly mapped, and don't get nuked along the way. + /// + /// - Input: [Unknown HTML][Tail Comment] + /// + /// - Output: [Unknown HTML] + /// + func testUnknownHtmlDoesNotGetNuked() { + let htmlAttachment = HTMLAttachment() + htmlAttachment.rawHTML = "
ROW ROW
" + + let commentAttachment = CommentAttachment() + commentAttachment.text = "Tail Comment" + + let htmlString = NSAttributedString(attachment: htmlAttachment) + let textString = NSAttributedString(string: "Some Text here?") + let commentString = NSAttributedString(attachment: commentAttachment) + + let testingString = NSMutableAttributedString() + testingString.append(htmlString) + testingString.append(textString) + testingString.append(commentString) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 3) + + guard let htmlNode = node.children[0] as? ElementNode, + let textNode = node.children[1] as? TextNode, + let commentNode = node.children[2] as? CommentNode + else { + XCTFail() + return + } + + let reconvertedHTML = Libxml2.Out.HTMLConverter().convert(htmlNode) + + XCTAssertEqual(reconvertedHTML, htmlAttachment.rawHTML) + XCTAssertEqual(textNode.contents, textString.string) + XCTAssertEqual(commentNode.comment, commentAttachment.text) + } + + + /// Verifies that Line Breaks get properly converted into BR Element, whenever the Leftmost + Rightmost elements + /// are just plain strings. + /// + /// - Input: Hello\nWorld + /// + /// - Output: Hello
World + /// + func testNewlineIsAddedBetweenTwoNonBlocklevelElements() { + let testingString = NSAttributedString(string: "Hello\nWorld") + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 3) + + guard let helloNode = node.children[0] as? TextNode, + let breakNode = node.children[1] as? ElementNode, + let worldNode = node.children[2] as? TextNode + else { + XCTFail() + return + } + + XCTAssertEqual(helloNode.contents, "Hello") + XCTAssertEqual(breakNode.name, "br") + XCTAssertEqual(worldNode.contents, "World") + } + + /// Verifies that Line Breaks do NOT get added into the Tree, whenever the Leftmost + Rightmost elements + /// are H1. + /// + /// - Input:

Hello\nWorld

+ /// + /// - Output:

Hello

World

+ /// + func testNewlineDoesNotGetAddedBetweenTwoBlocklevelElements() { + let formatter = HeaderFormatter(headerLevel: .h1, placeholderAttributes: nil) + let headingStyle = formatter.apply(to: Constants.sampleAttributes) + + let testingString = NSAttributedString(string: "Hello\nWorld", attributes: headingStyle) + + // Convert + Verify + let node = rootNode(from: testingString) + guard node.children.count == 2 else { + XCTFail() + return + } + + guard let headingElementNode = node.children[0] as? ElementNode, + let worldElementNode = node.children[1] as? ElementNode, + let helloTextNode = headingElementNode.children[0] as? TextNode, + let worldTextNode = worldElementNode.children[0] as? TextNode + else { + XCTFail() + return + } + + XCTAssertEqual(headingElementNode.name, "h1") + XCTAssertEqual(worldElementNode.name, "h1") + XCTAssertEqual(helloTextNode.contents, "Hello") + XCTAssertEqual(worldTextNode.contents, "World") + } + + + /// Verifies that a List placed within a blockquote gets properly merged, when a Paragraph Break is detected + /// (and split in two different paragraphs!). + /// + /// - Input:
  • First Line\nSecond Line
+ /// + /// - Output:
  • First Line
  • Second Line
+ /// + func testNestedListWithinBlockquoteGetsProperlyMerged() { + let firstText = "First Line" + let secondText = "Second Line" + + let text = firstText + String(.lineFeed) + secondText + let testingString = NSMutableAttributedString(string: text, attributes: Constants.sampleAttributes) + let testingRange = testingString.rangeOfEntireString + + BlockquoteFormatter().applyAttributes(to: testingString, at: testingRange) + TextListFormatter(style: .ordered).applyAttributes(to: testingString, at: testingRange) + + // Convert + Verify + let node = rootNode(from: testingString) + XCTAssert(node.children.count == 1) + + guard let blockquoteElementNode = node.children.first as? ElementNode, + blockquoteElementNode.name == "blockquote", + blockquoteElementNode.children.count == 1 + else { + XCTFail() + return + } + + guard let unorderedElementNode = blockquoteElementNode.children.first as? ElementNode, + unorderedElementNode.name == "ol", + unorderedElementNode.children.count == 2 + else { + XCTFail() + return + } + + guard let firstListItem = unorderedElementNode.children[0] as? ElementNode, + let secondListItem = unorderedElementNode.children[1] as? ElementNode, + firstListItem.name == "li", + secondListItem.name == "li", + firstListItem.children.count == 1, + secondListItem.children.count == 1 + else { + XCTFail() + return + } + + let firstTextItem = firstListItem.children.first as? TextNode + let secondTextItem = secondListItem.children.first as? TextNode + + XCTAssertEqual(firstTextItem?.contents, firstText) + XCTAssertEqual(secondTextItem?.contents, secondText) + } + + + /// Verifies that two paragraphs consisting on