Add RTE formatting buttons (#1614)

* Add RTE formatting buttons

* Update UUID as stored properties

* Disable autocorrection on URL fields

* Alert ids to let

* Add ComposerToolbar_Previews

* Cleanup

* Cleanup

* Refactor FormatItem colors

* Fix composer layout issue

* Fix ui tests

* Nest ComposerToolbar A11y ids under RoomScreen

* Add composer reply ui tests

* Add UTs

---------

Co-authored-by: Alfonso Grillo <alfogrillo@gmail.com>
This commit is contained in:
aringenbach
2023-09-05 17:39:54 +02:00
committed by GitHub
parent d8fa82da76
commit c9962a4459
47 changed files with 863 additions and 47 deletions
@@ -14,7 +14,9 @@
// limitations under the License.
//
import SwiftUI
import UIKit
import WysiwygComposer
enum ComposerToolbarViewModelAction {
case sendMessage(plain: String, html: String, mode: RoomScreenComposerMode)
@@ -43,6 +45,8 @@ enum ComposerToolbarViewAction {
case displayLocationPicker
case displayPollForm
case handlePasteOrDrop(provider: NSItemProvider)
case enableTextFormatting
case composerAction(action: ComposerAction)
}
struct ComposerToolbarViewState: BindableState {
@@ -61,8 +65,11 @@ struct ComposerToolbarViewState: BindableState {
}
struct ComposerToolbarViewStateBindings {
var composerPlainText: String
var composerFocused: Bool
var composerPlainText = ""
var composerFocused = false
var composerActionsEnabled = false
var formatItems: [FormatItem] = .init()
var alertInfo: AlertInfo<UUID>?
var showAttachmentPopover = false {
didSet {
@@ -72,3 +79,167 @@ struct ComposerToolbarViewStateBindings {
}
}
}
/// An item in the toolbar
struct FormatItem {
/// The type of the item
let type: FormatType
/// The state of the item
let state: ActionState
}
/// The types of formatting actions
enum FormatType {
case bold
case italic
case underline
case strikeThrough
case unorderedList
case orderedList
case indent
case unindent
case inlineCode
case codeBlock
case quote
case link
}
extension FormatType: CaseIterable, Identifiable {
var id: Self { self }
}
extension FormatItem: Identifiable {
var id: FormatType { type }
}
extension FormatItem {
/// The icon to display in the formatting toolbar.
var icon: Image {
switch type {
case .bold:
return Image(asset: Asset.Images.bold)
case .italic:
return Image(asset: Asset.Images.italic)
case .underline:
return Image(asset: Asset.Images.underline)
case .strikeThrough:
return Image(asset: Asset.Images.strikethrough)
case .unorderedList:
return Image(asset: Asset.Images.bulletList)
case .orderedList:
return Image(asset: Asset.Images.numberedList)
case .indent:
return Image(asset: Asset.Images.indent)
case .unindent:
return Image(asset: Asset.Images.unindent)
case .inlineCode:
return Image(asset: Asset.Images.inlineCode)
case .codeBlock:
return Image(asset: Asset.Images.codeBlock)
case .quote:
return Image(asset: Asset.Images.quote)
case .link:
return Image(asset: Asset.Images.link)
}
}
var accessibilityIdentifier: String {
switch type {
case .bold:
return A11yIdentifiers.roomScreen.composerToolbar.bold
case .italic:
return A11yIdentifiers.roomScreen.composerToolbar.italic
case .underline:
return A11yIdentifiers.roomScreen.composerToolbar.underline
case .strikeThrough:
return A11yIdentifiers.roomScreen.composerToolbar.strikethrough
case .unorderedList:
return A11yIdentifiers.roomScreen.composerToolbar.unorderedList
case .orderedList:
return A11yIdentifiers.roomScreen.composerToolbar.orderedList
case .indent:
return A11yIdentifiers.roomScreen.composerToolbar.indent
case .unindent:
return A11yIdentifiers.roomScreen.composerToolbar.unindent
case .inlineCode:
return A11yIdentifiers.roomScreen.composerToolbar.inlineCode
case .codeBlock:
return A11yIdentifiers.roomScreen.composerToolbar.codeBlock
case .quote:
return A11yIdentifiers.roomScreen.composerToolbar.quote
case .link:
return A11yIdentifiers.roomScreen.composerToolbar.link
}
}
var accessibilityLabel: String {
switch type {
case .bold:
return L10n.richTextEditorFormatBold
case .italic:
return L10n.richTextEditorFormatItalic
case .underline:
return L10n.richTextEditorFormatUnderline
case .strikeThrough:
return L10n.richTextEditorFormatStrikethrough
case .unorderedList:
return L10n.richTextEditorBulletList
case .orderedList:
return L10n.richTextEditorNumberedList
case .indent:
return L10n.richTextEditorIndent
case .unindent:
return L10n.richTextEditorUnindent
case .inlineCode:
return L10n.richTextEditorInlineCode
case .codeBlock:
return L10n.richTextEditorCodeBlock
case .quote:
return L10n.richTextEditorQuote
case .link:
return L10n.richTextEditorLink
}
}
}
extension FormatType {
/// The associated library composer action.
var composerAction: ComposerAction {
switch self {
case .bold:
return .bold
case .italic:
return .italic
case .underline:
return .underline
case .strikeThrough:
return .strikeThrough
case .unorderedList:
return .unorderedList
case .orderedList:
return .orderedList
case .indent:
return .indent
case .unindent:
return .unindent
case .inlineCode:
return .inlineCode
case .codeBlock:
return .codeBlock
case .quote:
return .quote
case .link:
return .link
}
}
/// Return true if the format type is an indentation action.
var isIndentType: Bool {
switch self {
case .indent, .unindent:
return true
default:
return false
}
}
}
@@ -15,6 +15,8 @@
//
import Combine
import Foundation
import SwiftUI
import WysiwygComposer
typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction>
@@ -26,10 +28,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
actionsSubject.eraseToAnyPublisher()
}
private struct WysiwygLinkData {
let action: LinkAction
let range: NSRange
var url: String
var text: String
}
private var currentLinkData: WysiwygLinkData?
init(wysiwygViewModel: WysiwygComposerViewModel) {
self.wysiwygViewModel = wysiwygViewModel
super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerPlainText: "", composerFocused: false)))
super.init(initialViewState: ComposerToolbarViewState(bindings: .init()))
context.$viewState
.map(\.composerMode)
@@ -46,6 +57,20 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
wysiwygViewModel.$isContentEmpty
.weakAssign(to: \.state.composerEmpty, on: self)
.store(in: &cancellables)
wysiwygViewModel.$actionStates
.map { actions in
FormatType
.allCases
// Exclude indent type outside of lists.
.filter { wysiwygViewModel.isInList || !$0.isIndentType }
.map { type in
FormatItem(type: type,
state: actions[type.composerAction] ?? .disabled)
}
}
.weakAssign(to: \.state.bindings.formatItems, on: self)
.store(in: &cancellables)
}
// MARK: - Public
@@ -65,6 +90,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
actionsSubject.send(.sendPlainTextMessage(message: context.composerPlainText,
mode: state.composerMode))
}
state.bindings.composerActionsEnabled = false
case .cancelReply:
set(mode: .default)
case .cancelEdit:
@@ -82,6 +108,15 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
actionsSubject.send(.displayPollForm)
case .handlePasteOrDrop(let provider):
actionsSubject.send(.handlePasteOrDrop(provider: provider))
case .enableTextFormatting:
state.bindings.composerActionsEnabled = true
state.bindings.composerFocused = true
case .composerAction(let action):
if action == .link {
createLinkAlert()
} else {
wysiwygViewModel.apply(action)
}
}
}
@@ -128,4 +163,124 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
state.bindings.composerPlainText = text
}
}
private func createLinkAlert() {
let linkAction = wysiwygViewModel.getLinkAction()
currentLinkData = WysiwygLinkData(action: linkAction,
range: wysiwygViewModel.attributedContent.selection,
url: linkAction.url ?? "",
text: "")
let urlBinding: Binding<String> = .init { [weak self] in
self?.currentLinkData?.url ?? ""
} set: { [weak self] value in
self?.currentLinkData?.url = value
}
let textBinding: Binding<String> = .init { [weak self] in
self?.currentLinkData?.text ?? ""
} set: { [weak self] value in
self?.currentLinkData?.text = value
}
switch linkAction {
case .createWithText:
state.bindings.alertInfo = makeCreateWithTextAlertInfo(urlBinding: urlBinding, textBinding: textBinding)
case .create:
state.bindings.alertInfo = makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: false)
case .edit:
state.bindings.alertInfo = makeEditChoiceAlertInfo(urlBinding: urlBinding)
case .disabled:
break
}
}
private func makeCreateWithTextAlertInfo(urlBinding: Binding<String>, textBinding: Binding<String>) -> AlertInfo<UUID> {
AlertInfo(id: UUID(),
title: L10n.richTextEditorCreateLink,
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel, action: {
self.restoreComposerSelectedRange()
}),
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave, action: {
self.restoreComposerSelectedRange()
self.createLinkWithText()
}),
textFields: [AlertInfo<UUID>.AlertTextField(placeholder: L10n.commonText,
text: textBinding,
autoCapitalization: .never,
autoCorrectionDisabled: false),
AlertInfo<UUID>.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder,
text: urlBinding,
autoCapitalization: .never,
autoCorrectionDisabled: true)])
}
private func makeSetUrlAlertInfo(urlBinding: Binding<String>, isEdit: Bool) -> AlertInfo<UUID> {
AlertInfo(id: UUID(),
title: isEdit ? L10n.richTextEditorEditLink : L10n.richTextEditorCreateLink,
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel, action: {
self.restoreComposerSelectedRange()
}),
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave, action: {
self.restoreComposerSelectedRange()
self.setLink()
}),
textFields: [AlertInfo<UUID>.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder,
text: urlBinding,
autoCapitalization: .never,
autoCorrectionDisabled: true)])
}
private func makeEditChoiceAlertInfo(urlBinding: Binding<String>) -> AlertInfo<UUID> {
AlertInfo(id: UUID(),
title: L10n.richTextEditorEditLink,
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionRemove, role: .destructive, action: {
self.restoreComposerSelectedRange()
self.removeLinks()
}),
verticalButtons: [AlertInfo<UUID>.AlertButton(title: L10n.actionEdit, action: {
self.state.bindings.alertInfo = nil
DispatchQueue.main.async {
self.state.bindings.alertInfo = self.makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: true)
}
})])
}
private func restoreComposerSelectedRange() {
guard let currentLinkData else { return }
wysiwygViewModel.select(range: currentLinkData.range)
}
private func setLink() {
guard let currentLinkData else { return }
wysiwygViewModel.applyLinkOperation(.setLink(urlString: currentLinkData.url))
}
private func createLinkWithText() {
guard let currentLinkData else { return }
wysiwygViewModel.applyLinkOperation(.createLink(urlString: currentLinkData.url,
text: currentLinkData.text))
}
private func removeLinks() {
wysiwygViewModel.applyLinkOperation(.removeLinks)
}
}
private extension WysiwygComposerViewModel {
/// Return true if the selection of the composer is currently located in a list.
var isInList: Bool {
actionStates[.orderedList] == .reversed || actionStates[.unorderedList] == .reversed
}
}
private extension LinkAction {
var url: String? {
guard case .edit(let url) = self else {
return nil
}
return url
}
}
@@ -26,29 +26,65 @@ struct ComposerToolbar: View {
@ScaledMetric private var sendButtonIconSize = 16
var body: some View {
VStack(spacing: 8) {
topBar
if context.composerActionsEnabled {
bottomBar
}
}
.alert(item: $context.alertInfo)
}
private var topBar: some View {
HStack(alignment: .bottom, spacing: 10) {
RoomAttachmentPicker(context: context)
.padding(.bottom, 5) // centre align with the send button
if !context.composerActionsEnabled {
RoomAttachmentPicker(context: context)
.padding(.bottom, 5) // centre align with the send button
}
messageComposer
.environmentObject(context)
Button {
context.send(viewAction: .sendMessage)
} label: {
submitButtonImage
.symbolVariant(.fill)
.font(.compound.bodyLG)
.foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .global.white)
.background {
Circle()
.foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary)
}
if !context.composerActionsEnabled {
sendButton
}
.disabled(context.viewState.sendButtonDisabled)
.animation(.linear(duration: 0.1), value: context.viewState.sendButtonDisabled)
.keyboardShortcut(.return, modifiers: [.command])
.padding([.vertical, .trailing], 6)
}
}
private var bottomBar: some View {
HStack(alignment: .bottom, spacing: 10) {
Button {
context.composerActionsEnabled = false
} label: {
Image(systemName: "xmark.circle.fill")
.font(.compound.headingLG)
.foregroundColor(.compound.textActionPrimary)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions)
.padding(.bottom, 5) // centre align with the send button
FormattingToolbar(formatItems: context.formatItems) { action in
context.send(viewAction: .composerAction(action: action.composerAction))
}
sendButton
}
}
private var sendButton: some View {
Button {
context.send(viewAction: .sendMessage)
} label: {
submitButtonImage
.symbolVariant(.fill)
.font(.compound.bodyLG)
.foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .global.white)
.background {
Circle()
.foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary)
}
}
.disabled(context.viewState.sendButtonDisabled)
.animation(.linear(duration: 0.1), value: context.viewState.sendButtonDisabled)
.keyboardShortcut(.return, modifiers: [.command])
.padding([.vertical, .trailing], 6)
}
private var messageComposer: some View {
MessageComposer(plainText: $context.composerPlainText,
@@ -109,6 +145,12 @@ struct ComposerToolbar: View {
}
}
struct ComposerToolbar_Previews: PreviewProvider {
static var previews: some View {
ComposerToolbar.mock()
}
}
// MARK: - Mock
extension ComposerToolbar {
@@ -0,0 +1,68 @@
//
// Copyright 2023 New Vector Ltd
//
// 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.
//
import SwiftUI
struct FormattingToolbar: View {
/// The list of items to render in the toolbar
var formatItems: [FormatItem]
/// The action when an item is selected
var formatAction: (FormatType) -> Void
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 4) {
ForEach(formatItems) { item in
Button {
formatAction(item.type)
} label: {
item.icon
.renderingMode(.template)
.foregroundColor(item.foregroundColor)
}
.disabled(item.state == .disabled)
.frame(width: 44, height: 44)
.background(item.backgroundColor)
.cornerRadius(8)
.accessibilityIdentifier(item.accessibilityIdentifier)
.accessibilityLabel(item.accessibilityLabel)
}
}
}
}
}
private extension FormatItem {
var foregroundColor: Color {
switch state {
case .reversed:
return .compound.iconOnSolidPrimary
case .enabled:
return .compound.iconSecondary
case .disabled:
return .compound.iconDisabled
}
}
var backgroundColor: Color {
switch state {
case .reversed:
return .compound.bgActionPrimaryRest
default:
return .compound.bgCanvasDefault
}
}
}
@@ -59,7 +59,7 @@ struct MessageComposer: View {
}
}
}
.padding([.leading, .trailing], 12.0)
.padding(.horizontal, 12.0)
.clipped()
.background {
ZStack {
@@ -107,16 +107,16 @@ private struct MessageComposerReplyHeader: View {
.padding(4.0)
.background(Color.compound.bgCanvasDefault)
.cornerRadius(13.0)
.padding([.trailing, .vertical], 8.0)
.padding([.leading], -4.0)
.overlay(alignment: .topTrailing) {
Button(action: action) {
Image(systemName: "xmark")
.font(.compound.bodySM.weight(.medium))
.foregroundColor(.compound.iconTertiary)
.padding(16.0)
.padding(8.0)
}
}
.padding(.vertical, 8.0)
.padding(.horizontal, -4.0)
}
}
@@ -31,7 +31,7 @@ struct RoomAttachmentPicker: View {
.font(.compound.headingLG)
.foregroundColor(.compound.textActionPrimary)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPicker)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions)
.popover(isPresented: $context.showAttachmentPopover) {
VStack(alignment: .leading, spacing: 0.0) {
Button {
@@ -72,6 +72,17 @@ struct RoomAttachmentPicker: View {
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourcePoll, icon: Image(asset: Asset.Images.timelinePollAttachment))
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPoll)
if ServiceLocator.shared.settings.richTextEditorEnabled {
Button {
context.showAttachmentPopover = false
context.send(viewAction: .enableTextFormatting)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentTextFormatting, icon: Image(asset: Asset.Images.textFormat))
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerTextFormatting)
}
}
.padding(.top, isPresented ? 20 : 0)
.background {