diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift index d0ae46d7b..8fb7b65da 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift @@ -121,7 +121,7 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa .foregroundColor: parserStyle.textColor] } - private var hasPendingFormats = false + private(set) var hasPendingFormats = false // MARK: - Public @@ -183,8 +183,8 @@ public extension WysiwygComposerViewModel { let update = model.apply(action) if update.textUpdate() == .keep { hasPendingFormats = true - } else if action == .codeBlock || action == .quote, attributedContent.selection.length == 0 { - // Add code block/quote as a pending format to improve block display. + } else if attributedContent.selection.length == 0, action.requiresReapplyFormattingOnEmptySelection { + // Set pending format if current action requires it. hasPendingFormats = true } applyUpdate(update) @@ -479,7 +479,12 @@ private extension WysiwygComposerViewModel { do { let htmlSelection = NSRange(location: Int(start), length: Int(end - start)) let textSelection = try attributedContent.text.attributedRange(from: htmlSelection) - attributedContent.selection = textSelection + if textSelection != attributedContent.selection { + attributedContent.selection = textSelection + // Ensure we re-apply required pending formats when switching to a zero-length selection. + // This fixes selecting in and out of a list / quote / etc + hasPendingFormats = textSelection.length == 0 && !model.reversedActions.isEmpty + } Logger.viewModel.logDebug(["Sel(att): \(textSelection)", "Sel: \(htmlSelection)"], functionName: #function) diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/ComposerAction.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/ComposerAction.swift new file mode 100644 index 000000000..03b1aeea0 --- /dev/null +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/ComposerAction.swift @@ -0,0 +1,28 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +extension ComposerAction { + /// Returns `true` if action requires all current formatting to be re-applied on + /// next character stroke when triggered on an empty selection. + var requiresReapplyFormattingOnEmptySelection: Bool { + switch self { + case .bold, .italic, .strikeThrough, .underline, .inlineCode, .link, .undo, .redo: + return false + case .orderedList, .unorderedList, .indent, .unindent, .codeBlock, .quote: + return true + } + } +} diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift index b8db2eed9..e61b54b5e 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift @@ -152,6 +152,37 @@ final class WysiwygComposerViewModelTests: XCTestCase { .contains([.traitBold, .traitItalic]) ) } + + func testPendingFormatFlagInNewList() { + viewModel.apply(.bold) + viewModel.apply(.italic) + mockTrailingTyping("Text") + viewModel.enter() + // After creating a list, pending format flag is on + viewModel.apply(.orderedList) + XCTAssertTrue(viewModel.hasPendingFormats) + // Typing consumes the flag + mockTrailingTyping("Item") + XCTAssertFalse(viewModel.hasPendingFormats) + // Creating a second list item re-enables the flag + viewModel.enter() + XCTAssertTrue(viewModel.hasPendingFormats) + } + + func testPendingFormatFlagAfterReselectingListItem() { + viewModel.apply(.bold) + viewModel.apply(.italic) + mockTrailingTyping("Text") + viewModel.enter() + viewModel.apply(.orderedList) + let inListSelection = viewModel.attributedContent.selection + let insertedText = "Text" + mockTyping(insertedText, at: 0) + // After re-selecting the empty list item, pending format flag is still on + viewModel.select(range: NSRange(location: inListSelection.location + insertedText.utf16Length, + length: inListSelection.length)) + XCTAssertTrue(viewModel.hasPendingFormats) + } } // MARK: - WysiwygTestExpectation @@ -216,6 +247,7 @@ extension WysiwygComposerViewModelTests { if shouldAcceptChange { // Force apply since the text view should've updated by itself viewModel.textView.apply(viewModel.attributedContent) + viewModel.didUpdateText() } } @@ -239,6 +271,7 @@ extension WysiwygComposerViewModelTests { if shouldAcceptChange { // Force apply since the text view should've updated by itself viewModel.textView.apply(viewModel.attributedContent) + viewModel.didUpdateText() } }