基于 WordPress 开源的 AztecEditor-iOS库实现原生富文本编辑器
AztecEditor-iOS 基于 UITextView 提供了一个性能良好的用户界面组件。Android 端有对应的 SDK。
它由两个主要部分组成:
- TextView:用户界面组件,用于呈现和编辑内容,可预览文本展示样式和对应HTML。
- 转换器:实现HTML 和 NSAttributedString 的相互转换。
Aztec提供了丰富的富文本编辑功能,包括字体格式化(粗体、斜体、下划线、删除线、颜色等),引用块(可自定义样式),媒体文件(图片、视频)的展示和编辑,自定义标签(包括但不限于@、#等特殊字符可触发的自定义功能)等等。
鉴于 Aztec 的官方文档相当的简洁,开发者使用起来并没有那么方便,编者在实现编辑器的过程当中主要是参考了官方Demo。
这里不针对 Aztec 源码做过多解析,只对如何使用以及如何进行定制化功能做一些说明,都是笔者在实际开发当中使用到的。
插件和短码系统
Aztec 提供的插件和短码系统,可以让我们通过自定义插件和短码来实现定制功能。
插件的实现主要是基于Plugin类和Processor协议,搭配短码解析系统来进行功能定制。短码方案直接采用了 WordPress-Editor 的方案,这个方案主要由ShortcodeAttributeSerializer
、ShortcodeProcessor
和HTMLProcessor
组成,ShortcodeProcessor
内部通过正则匹配,可以识别形如[tag id="" type="" content=""]
的自定义短码,搭配ShortcodeAttributeSerializer
和HTMLProcessor
来进行正反方向的解析。
基于 Aztec 的插件和短码系统,我们可以扩展出用于自己项目的HTML与富文本相互转换的完全定制化系统,实际上 WordPress-Editor 的 Demo 就是这么做的。
open class Plugin {
// MARK: - Customizers
public let inputCustomizer: PluginInputCustomizer?
public let outputCustomizer: PluginOutputCustomizer?
// MARK: - Initializers
public init(inputCustomizer: PluginInputCustomizer? = nil, outputCustomizer: PluginOutputCustomizer? = nil) {
self.inputCustomizer = inputCustomizer
self.outputCustomizer = outputCustomizer
}
/// Method plugins can use to execute extra code when loaded.
///
open func loaded(textView: TextView) {}
// MARK: - Equatable
public static func ==(lhs: Plugin, rhs: Plugin) -> Bool {
return type(of: lhs) == type(of: rhs)
}
}
插件的功能主要依靠PluginInputCustomizer
和PluginOutputCustomizer
来实现。我们在初始化编辑器时进行加载即可。
let plugins = [RichEditorPlugin(), PasteboardPlugin()]
for plugin in plugins {
textView.load(plugin)
}
/// PluginInputCustomizer定义
public protocol PluginInputCustomizer {
func process(html: String) -> String
func process(htmlTree: RootNode)
func converter(for elementNode: ElementNode) -> ElementConverter?
}
/// PluginOutputCustomizer定义
public protocol PluginOutputCustomizer {
func process(html: String) -> String
func process(htmlTree: RootNode)
func convert(_ paragraphProperty: ParagraphProperty) -> ElementNode?
func convert(_ attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any]) -> [Node]?
func converter(for elementNode: ElementNode) -> ElementToTagConverter?
}
PluginInputCustomizer
可以和短码系统组合来为输入过程提供操作 HTML 的函数。
PluginOutputCustomizer
为输出过程提供操作 HTML 的函数。比如发布时,自定义输出 HTML 内容。
在获取 HTML 时,Aztec 会调用HTMLConverter
的html(from attributedString: NSAttributedString, prettify: Bool = false) -> String
函数来进行解析,将富文本转换成 HTML。
其内部借助 AttributedStringParser
和 ElementConverter
(如ImageAttachmentToElementConverter
、VideoAttachmentToElementConverter
)生成节点树,再通过HTMLSerializer
的serialize(_ node: Node, prettify: Bool = false) -> String
函数,将节点树处理成 HTML,以上过程的每个节点都可以通过遵循PluginOutputCustomizer
来进行干预。
class RichEditorPlugin: Plugin {
init() {
super.init(inputCustomizer: EditorInputCustomizer(),
outputCustomizer: EditorOutputCustomizer())
}
}
class EditorInputCustomizer: PluginInputCustomizer {
private var processor: PipelineProcessor = {
var processors = [VideoProcessor.previewProcessor, ImageProcessor.previewProcessor]
processors.append(contentsOf: CustomTagType.previewProcessors)
return PipelineProcessor(processors)
}()
func process(html: String) -> String {
return processor.process(html)
}
func process(htmlTree: Aztec.RootNode) {}
func converter(for elementNode: Aztec.ElementNode) -> (any Aztec.ElementConverter)? {
return nil
}
}
class EditorOutputCustomizer: PluginOutputCustomizer {
private var processor: PipelineProcessor = {
var processors = [VideoProcessor.postProcessor, ImageProcessor.postProcessor]
processors.append(contentsOf: CustomTagType.postProcessors)
return PipelineProcessor(processors)
}()
func process(html: String) -> String {
return processor.process(html)
}
func process(htmlTree: Aztec.RootNode) {}
func convert(_ paragraphProperty: Aztec.ParagraphProperty) -> Aztec.ElementNode? {
return nil
}
func convert(_ attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any]) -> [Aztec.Node]? {
return nil
}
func converter(for elementNode: Aztec.ElementNode) -> (any Aztec.ElementToTagConverter)? {
return nil
}
}
文本格式化
工具条
Aztec 提供了工具条FormatBar
和FormatBarItem
,在其基础上可做一些有限的自定义。
在实际开发中,开发者可以使用完全自定义的 UI + Aztec 提供的功能。
文本格式化
实现字体样式改变,比如粗体、斜体、下划线、删除线、引用块、标题可以直接调用 Aztec 提供的函数。插入自定义高亮标签,如 @someone 将在下一节进行说明。
private func handleFormat(_ style: EditorToolType.FormatFontStyle) {
switch style {
case .bold: /// 粗体文本
richTextView.toggleBold(range: richTextView.selectedRange)
case .italic: /// 斜体文本
richTextView.toggleItalic(range: richTextView.selectedRange)
case .underline: /// 下划线
richTextView.toggleUnderline(range: richTextView.selectedRange)
case .strikethrough: /// 删除线
richTextView.toggleStrikethrough(range: richTextView.selectedRange)
case .blockQuote: /// 引用块
richTextView.toggleBlockquote(range: richTextView.selectedRange)
case .headline: /// 标题
richTextView.toggleHeader(.h1, range: richTextView.selectedRange)
case .link: /// 添加链接
delegate?.shouldInsertLink(self)
case .horizontalRule: /// 分割线
richTextView.replaceWithHorizontalRuler(at: richTextView.selectedRange)
default:
break
}
}
实现的样式如下:
插入自定义标签
在我们项目当中,自定义标签各端约定了固定格式,比如固定格式可以是这样的:[[id, type, content]]
。通过插件和短码系统,编写自定义的短码处理器,可以完美实现需求。
短码的构造和解析
ShortcodeAttributeSerializer
和HTMLProcessor
组合可以将短码解析为 HTML,我们可以在解析过程中自定义 HTML 的内容,保证在编辑器中展示正确的样式。
static func parsedCustomTag(_ entity: CustomTagEntity) -> String {
/// 构造符合ShortcodeProcessor要求格式的短码, e.g. [mention type=\"1\" text=\"@iOS test\" id=\"123\"]
let shortCode = "[\(entity.type.identifier) type=\"\(entity.type.rawValue)\" text=\"\(entity.text)\" id=\"\(entity.id)\"]"
/// 构造处理器
let process = CustomTagsProcessor(tagType: entity.type)
/// 将短码解析成 HTML
return process.previewProcessor.process(shortCode)
}
var previewProcessor: Processor {
let serializer = ShortcodeAttributeSerializer()
let processor = ShortcodeProcessor(tag: tagType.identifier) { shortCode in
var html = "<mark style=\"color: #007AF5;\""
guard let type = shortCode.attributes["type"],
let text = shortCode.attributes["text"],
let id = shortCode.attributes["id"]
else {
return nil
}
html += serializer.serialize(key: "type", value: type.value) + " "
html += serializer.serialize(key: "text", value: text.value) + " "
html += serializer.serialize(key: "id", value: id.value) + " "
html += ">\(serializer.serialize(text.value))</mark>;
return html
}
return processor
}
HTML转自定义标签
ShortcodeAttributeSerializer
和ShortcodeProcessor
组合可以将 HTML 转为 自定义标签
在编辑器中展示时,为了方便previewProcessor使用了 mark 来标记自定义标签,所以在postProcessor中要使用HTMLProcessor找到 mark 标签进行解析。 Aztec 支持未知标签,底层将未知标签转换成 NSTextAttachment 的子类,实现该功能需要自定义 Attachment 和 Render,下面会讲到。
var postProcessor: Processor {
let serializer = ShortcodeAttributeSerializer()
let processor = HTMLProcessor(for: Element.mark.rawValue) { element in
guard let type = element.attributes["type"],
let text = element.attributes["text"],
let id = element.attributes["id"]
else { return nil }
let html = String(format: "[[%@,%@,%@]]", serializer.serialize(type.value), serializer.serialize(text.value), serializer.serialize(id.value))
return html
}
return processor
}
使用方法
基于上面的实现,统一了插入自定义标签的方法
enum CustomTagType: Int, CaseIterable {
case mention = 1
case link = 2
case other = 3
var identifier: String {
switch self {
case .mention: return "mention"
case .link: return "link"
case .other: return "other"
}
}
static var previewProcessors: [Processor] {
let processors = CustomTagType.allCases.map { CustomTagsProcessor(tagType: $0).previewProcessor }
return processors
}
static var postProcessors: [Processor] {
let processors = CustomTagType.allCases.map { CustomTagsProcessor(tagType: $0).postProcessor }
return processors
}
}
插入时按照上面提供的方案来进行即可。比如插入@someone:我们按照给定的格式提供一个短码 [mention type=\"1\" text=\"@iOS test\" id=\"123\"]
,经过previewProcessor
的处理后会得到如下 HTML,这样可以保证在编辑器中的正确展示。
<mark style=\"color: #007AF5;\" type=\"1\" text=\"@iOS test\" id=\"123\" >@iOS test</mark>;
上面的 HTML 经过postProcessor
的处理后会得到约定好的自定义标签的固定格式[[1,@iOS test,123]]
,这样可以保证发布时得到正确的内容。
自定义未知HTML标签
Aztec 支持未知HTML标签,其原理是将未知标签转换成 NSTextAttachment 的子类,所以要实现该功能就需要自定义 Attachment 和 Render。
Attachment用来表示未知标签,Render 用来保证正确渲染。Aztec 为我们提供了 HTMLAttachment 和HTMLAttachmentRenderer,但它们可能无法满足我们的需求,所以在使用时需要做定制。
下面是我们的自定义渲染器:
class CustomTagsRender: TextViewAttachmentImageProvider {
func textView(_ textView: TextView, shouldRender attachment: NSTextAttachment) -> Bool {
return attachment is HTMLAttachment
}
func textView(_ textView: TextView, imageFor attachment: NSTextAttachment, with size: CGSize) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(size, false, 0)
guard let attachment = attachment as? HTMLAttachment else {
return nil
}
let message = messageAttributedString(with: attachment)
let targetRect = boundingRect(for: message, size: size)
message.draw(in: targetRect)
let result = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return result
}
func textView(_ textView: TextView, boundsFor attachment: NSTextAttachment, with lineFragment: CGRect) -> CGRect {
guard let attachment = attachment as? HTMLAttachment else {
return .zero
}
let message = messageAttributedString(with: attachment)
let size = CGSize(width: lineFragment.size.width, height: lineFragment.size.height)
var rect = boundingRect(for: message, size: size)
rect.origin.y = UIFont.mainRegular(size: 16).descender
return rect.integral
}
}
private extension CustomTagsRender {
private func boundingRect(for message: NSAttributedString, size: CGSize) -> CGRect {
let targetBounds = message.boundingRect(with: size, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
let targetPosition = CGPoint(x: (size.width - targetBounds.width) * 0.5, y: (size.height - targetBounds.height) * 0.5)
return CGRect(origin: targetPosition, size: targetBounds.size)
}
private func messageAttributedString(with attachment: HTMLAttachment) -> NSAttributedString {
let attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.textLink,
.font: UIFont.mainRegular(size: 16)
]
/// 此处的 tagName 必须与 previewProcessor 和 postProcessor 中使用的标签名一致!!!
guard let text = extractContent(from: attachment.rawHTML, tagName: "test")
else {
return NSAttributedString(string: "[Unknown]", attributes: attributes)
}
return NSAttributedString(string: text, attributes: attributes)
}
private func extractContent(from html: String, tagName: String) -> String? {
do {
let document = try SwiftSoup.parse(html)
let elements = try document.getElementsByTag(tagName)
if let element = elements.first() {
let content = try element.text()
return content
}
} catch {
print("HTML 解析错误: \(error)")
}
return nil
}
}
效果展示:
使用该方案可以一键删除 attachment,不支持光标移入,而且 attachment 支持点击,可以做一些扩展操作。缺点就是存在换行展示问题。
插入图片、视频
对于图像、视频、分隔符和表格等嵌入元素,使用的是NSTextAttachment的子类:
插入图片和视频时采用下面的方法,等待上传完成后,通过 identifier 获取对应 attachment,替换必要数据即可。
replaceWithImage(at range: NSRange, sourceURL url: URL, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> ImageAttachment
replaceWithVideo(at range: NSRange, sourceURL: URL, posterURL: URL?, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> VideoAttachment
图片、视频的自定义属性
Aztec 提供的 Attachment 的属性可能无法满足我们的需求,比如项目需要保留标志视频长度的data-duration
属性,这个 Aztec 并没有提供。
幸运的是 Aztec 为我们提供了extraAttributes
字段,并且支持通过对extraAttributes
进行解析来实现添加任意属性的功能。
func convert(_ attachment: VideoAttachment, attributes: [NSAttributedString.Key : Any]) -> [Node] {
let element: ElementNode
...
/// 这里将extraAttributes中的自定义属性附加到了 element 上
for attribute in attachment.extraAttributes {
element.updateAttribute(named: attribute.name, value: attribute.value)
}
element.children = element.children + videoSourceElements(from: attachment)
return [element]
}
我们为 VideoAttachment 扩展了自定义属性,然后利用extraAttributes
来实现我们的需求,使用时为自定义属性赋值,并在 processor 中进行解析。
extension VideoAttachment {
@objc var videoId: String? {
get {
return extraAttributes[MediaHTMLAttribute.videoAttribute]?.toString()
}
set {
if let nonNilValue = newValue {
extraAttributes[MediaHTMLAttribute.videoAttribute] = .string(nonNilValue)
} else {
extraAttributes.remove(named: MediaHTMLAttribute.videoAttribute)
}
}
}
var duration: String? {
get {
return extraAttributes[MediaHTMLAttribute.videoDuration]?.toString()
}
set {
if let nonNilValue = newValue {
extraAttributes[MediaHTMLAttribute.videoDuration] = .string(nonNilValue)
} else {
extraAttributes.remove(named: MediaHTMLAttribute.videoDuration)
}
}
}
}
图片和视频 processor 输出 HTML 的代码如下:
static var postProcessor: Processor {
let serializer = ShortcodeAttributeSerializer()
let processor = HTMLProcessor(for: "img", replacer: { element in
var html = "<img "
if let imgId = element.attributes[MediaAttachment.uploadKey] {
html += "\(serializer.serialize(key: "id", value: imgId.value))" + " "
}
if let src = element.attributes["src"] {
html += "\(serializer.serialize(key: "src", value: src.value))" + " "
}
html += ">"
return html
})
return processor
}
static var postProcessor: Processor {
let serializer = ShortcodeAttributeSerializer()
let processor = HTMLProcessor(for: "video", replacer: { element in
var html = "<video "
if let videoId = element.attributes[MediaAttachment.uploadKey] {
html += "\(serializer.serialize(key: "id", value: videoId.value))" + " "
}
if let src = element.attributes["src"] {
html += "\(serializer.serialize(key: "src", value: src.value))" + " "
}
if let width = element.attributes["width"] {
html += "\(serializer.serialize(key: "w", value: width.value))" + " "
}
if let height = element.attributes["height"] {
html += "\(serializer.serialize(key: "h", value: height.value))" + " "
}
if let posterAttribute = element.attributes["poster"],
case let .string(posterValue) = posterAttribute.value,
let posterURL = URL(string: posterValue),
!posterURL.isFileURL
{
html += serializer.serialize(posterAttribute) + " "
}
if let duration = element.attributes["duration"] {
html += "\(serializer.serialize(key: "data-duration", value: duration.value))" + " "
}
html += ">"
return html
})
return processor
}
总结
Aztec能够实现的功能远不止上面介绍到的,它还可以实现列表、自定义引用块、自定义媒体文件展示样式等等,这些功能需要开发者在自行探究中进行持续的发掘。