iOS 14.5+ MacCatalyst 14.5+ Twitter: @stevengharris

MarkupEditor

SwiftUI 和 UIKit 应用的 WYSIWYG 编辑器。

羡慕那些 JavaScript 程序员拥有 WYSIWYG 文本编辑器,但不愿意将一个集成到你舒适的 Swift 世界中?是的,我也是。所以,当我在另一个项目中这样做时,我想我应该分享我的做法,以帮助其他人避免这种情况。

演示

MarkupEditor

MarkupEditor 的目标与非目标

我正在开发一个更大的项目,该项目需要嵌入式支持“富文本”编辑。WYSIWYG 编辑对我来说是必须的要求。我可以强迫我的开发者用户使用 Markdown,但我发现它在编写和查看时都很烦人。谁想在屏幕上费力地过滤掉所有那些无用的东西呢?当然,这比编辑原始 HTML 好得多;但是拜托,现在都 21 世纪了。必须处理使用某种“预览模式”来确保您编写的内容将以您期望的方式呈现的编辑体验,感觉就像写作的 CI/CD。

尽管如此,我还是想要一种不会妨碍我的编辑体验。我想要一些具有 Markdown 的功能简洁性,但以干净的、所见即所得的方式呈现,并支持人们期望的基本功能。

  1. 样式
    • 以预定义的字体大小呈现段落或标题
    • 项目符号和编号列表
    • 文本的缩进和减少缩进
  2. 格式化
    • 粗体、斜体、下划线、代码、删除线、下标和上标
  3. 嵌入
    • 图片
    • 表格
    • 链接

正如您可能预料的那样,此功能集与 Markdown 非常接近——或者至少是 GitHub 风格的 Markdown。一旦您进行 WYSIWYG 编辑,您必须正确支持撤消/重做。功能列表不包括您可能从您最喜欢的文字处理器中期望的一些内容

如果您想要更丰富的功能集,您可以自己扩展 MarkupEditor。演示包括如何扩展 MarkupEditor 的核心功能以及如何与文件系统交互以选择要编辑的内容的示例。我的目的是保持核心 MarkupEditor 功能集与您在 GitHub Markdown 中看到的功能集相似。

什么是真正的 WYSIWYG?

MarkupEditor 在您编辑时向您呈现一个 HTML 文档。它使用一个 JavaScript 库 ProseMirror 来更改底层 DOM,并在您与文档交互时回调到 Swift。MarkupEditor 不知道如何保存您的文档或将其转换为其他格式。这是您的应用程序(使用 MarkupEditor 的应用程序)需要做的事情。当底层文档状态发生更改时,MarkupEditor 将通知您的 MarkupDelegate,您可以利用这些通知来保存并可能将 HTML 转换为另一种形式。如果您要这样做,那么您应该确保往返转换回 HTML 也能完美运行。否则,您使用的就是“所见非所得”编辑器,这既不顺口,对您的最终用户来说也远不如“所见即所得”编辑器有用。

安装 MarkupEditor

您可以将 Swift 包安装到您的项目中,或者您可以自己构建 MarkupEditor 框架并使其成为依赖项。

Swift 包

使用 File -> Swift Packages -> Add Package Dependency... 将 MarkupEditor 包添加到您的 Xcode 项目。

框架

克隆此存储库并在 Xcode 中构建 MarkupFramework 目标。将 MarkupEditor.framework 作为依赖项添加到您的项目。

使用 MarkupEditor

在幕后,MarkupEditor 与 HTML 文档(在 markup.html 中创建)交互,该文档使用单个 contentEditable DIV 元素来修改您正在编辑的文档的 DOM。它使用 WKWebView 的子类 - MarkupWKWebView - 来调用 markup.js 中的 JavaScript。反过来,JavaScript 回调到 Swift,让 Swift 端知道发生了更改。Swift 端的回调由 MarkupCoordinator 处理。MarkupCoordinator 是单个 MarkupWKWebViewWKScriptMessageHandler,并在 userContentController(_:didReceive:) 中接收所有 JavaScript 回调。MarkupCoordinator 反过来通知您的 MarkupDelegate 更改。有关完整协议和默认实现,请参阅 MarkupDelegate.swift

这听起来很复杂,但这主要是您不必担心的实现细节。您的应用程序通常会使用 SwiftUI 的 MarkupEditorView 或 UIKit 的 MarkupEditorUIViewMarkupDelegate 协议是您的应用程序了解用户与文档交互时发生的更改的关键机制。您通常会让您的主 SwiftUI ContentView 或您的 UIKit UIViewController 成为您的 MarkupDelegate。您可以使用 MarkupEditor 结构自定义 MarkupEditor 的行为(例如,MarkupEditor.toolbarStyle = .compact)。

MarkupToolbar 是一个方便的、预构建的 UI,用于通过与 MarkupWKWebView 交互来调用对文档的更改。您不需要使用它,但如果您使用它,那么设置它的最简单方法是让 MarkupEditorViewMarkupEditorUIView 自动处理它。您的应用程序可能需要与 MarkupEditorViewMarkupEditorUIView 提供的工具栏不同的东西。例如,您可能需要多个 MarkupEditorViews 来共享单个 MarkupToolbar。在这种情况下,您应该指定 MarkupEditor.toolbarPosition = .none。然后,对于 SwiftUI,将 MarkupEditorViewMarkupToolbar 一起用作标准 SwiftUI 视图,通过响应 MarkupDelegate 中的 markupTookFocus(_:) 回调来标识 MarkupEditor.selectedWebView。对于 UIKit,您可以使用 MarkupEditorUIViewMarkupToolbarUIView。有关详细信息,请参阅 MarkupEditorViewMarkupEditorUIView 中的代码。

为了避免 Xcode 控制台中来自底层 WKWebView 的虚假日志记录,您可以在目标的 Run 属性中将 OS_ACTIVITY_MODE 设置为 disable。但是,这具有从 MarkupEditor 中删除 OSLog 消息的副作用,并且通常可能不是一个好主意。

SwiftUI 用法

在最简单的情况下,只需像使用任何其他 SwiftUI 视图一样使用 MarkupEditorView 即可。默认情况下,在除手机设备以外的所有设备上,它将在包含 MarkupWKWebViewUIViewRepresentable 上方放置一个 MarkupToolbar,您可以在其中进行编辑。在手机设备上,它会将工具栏设置为 MarkupWKWebViewinputAccessoryView,以便在键盘弹出时访问工具栏。您的 ContentView 可以充当 MarkupDelegate,这几乎肯定是您在除最简单的应用程序之外的所有应用程序中都想要做的事情。如果您自己未指定,则 MarkupEditorView 充当 MarkupDelegate

import SwiftUI
import MarkupEditor

struct SimplestContentView: View {
    
    @State private var demoHtml: String = "<h1>Hello World</h1>"
    
    var body: some View {
        MarkupEditorView(html: $demoHtml)
    }
    
}

UIKit 用法

在最简单的情况下,只需像使用任何其他 UIKit 视图一样使用 MarkupEditorUIView 即可。默认情况下,在除手机设备以外的所有设备上,它将在 MarkupWKWebView 上方放置一个 MarkupToolbarUIView,您可以在其中进行编辑。在手机设备上,它会将工具栏设置为 MarkupWKWebViewinputAccessoryView,以便在键盘弹出时访问工具栏。您的 ViewController 可以充当 MarkupDelegate,这几乎肯定是您在除最简单的应用程序之外的所有应用程序中都想要做的事情。如果您自己未指定,则 MarkupEditorUIView 充当 MarkupDelegate

import UIKit
import MarkupEditor

class SimplestViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let markupEditorUIView = MarkupEditorUIView(html: "<h1>Hello World</h1>")
        markupEditorUIView.frame = view.frame
        markupEditorUIView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(markupEditorUIView)
    }
    
}

获取已编辑的 HTML

在您编辑文档时,您可以看到其内容以正确的 WYSIWYG 方式更改。文档 HTML 不会 在您进行更改时自动传递回 Swift。您必须在应用程序中的适当位置使用 MarkupWKWebView.getHtml() 检索 HTML。这就引出了一个问题:什么是“适当的位置”?答案取决于您如何使用 MarkupEditor。在演示中,您可以在键入时显示 HTML,HTML 是使用 MarkupDelegate.markupInput(_:) 方法在每次击键时检索的。这通常是一个坏主意,因为它使键入比应有的更繁重。您可能只在用户按下“保存”按钮时才检索编辑后的 HTML。您可能想要通过使用 MarkupDelegate.markupInput(_:) 跟踪何时发生更改来实施自动保存类型的方法,并且仅在经过足够的时间或发生足够的更改时才调用 MarkupWKWebView.getHtml()

getHtml() 方法需要在 MarkupWKWebView 实例上调用。通常,您需要在您的 MarkupDelegate 中保留该实例。您几乎可以在所有 MarkupDelegate 方法(例如,MarkupWKWebView.markupLoadedMarkupWKWebView.markupInput)中访问它。使用 MarkupEditor.selectedWebView 获取实例将不可靠,因为当没有 MarkupWKWebView 获得焦点时,该值将变为 nil。

请注意,在 SwiftUI 中,当您将 HTML 传递给 MarkupEditorView 时,您传递的是 String 的绑定。例如

@State private var demoHtml: String = "<h1>Hello World</h1>"
    
var body: some View {
    MarkupEditorView(html: $demoHtml)
}

在此示例中,demoHtml 不会 在您编辑文档时被 MarkupEditor 修改。html 作为绑定传递,以便您可以从您的应用程序修改它。您必须使用 MarkupWKWebView.getHtml() 获取修改后的 HTML。

自定义 MarkupEditor

您可以对 MarkupToolbar 和 MarkupEditor 的整体行为进行一些有限的自定义。您应该始终在应用程序生命周期的早期进行这些自定义。

您还可以提供您自己的基于 CSS 的样式自定义和 JavaScript 脚本,供 MarkupEditor 在您的应用程序中使用。CustomContentView 和 CustomViewController 演示了在 demo.html 文档上使用自定义 CSS 和脚本的用法,并在下面讨论。

自定义工具栏

您可以使用仅包含按钮的紧凑型工具栏,也可以使用标记形式的工具栏,该工具栏显示每个按钮的功能。默认样式是标记形式。如果您想使用紧凑形式,请将 MarkupEditor.style 设置为 .compact

您可以通过消除它们和/或子集化其内容来自定义各种工具栏。您可以通过创建 ToolbarContents 的新实例并将其分配给 ToolbarContents.custom 来完成此操作。MarkupMenu 也使用 ToolbarContents 来自定义它包含的内容,因此在创建 MarkupMenu之前 设置 ToolbarContents.custom 非常重要。一种简单的方法是通过覆盖 init() 在您的 AppDelegate 中进行设置。这是一个示例,它添加了 CorrectionToolbar(包含 UndoRedo 按钮,默认情况下已关闭)并且仅在 FormatToolbar 中包含粗体、斜体和下划线作为格式。它还设置为使用紧凑型样式并允许本地图像(如下所述)

override init() {
    MarkupEditor.style = .compact
    MarkupEditor.allowLocalImages = true
    let myToolbarContents = ToolbarContents(
        correction: true,  // Off by default but accessible via menu, hotkeys, inputAccessoryView
        // Remove code, strikethrough, subscript, and superscript as formatting options
        formatContents: FormatContents(code: false, strike: false, subSuper: false)
    )
    ToolbarContents.custom = myToolbarContents
}

请注意,MarkupToolbar 使用静态值 MarkupEditor.selectedWebView 来确定要在哪个 MarkupWKWebView 上调用操作。这意味着通常您应该只有一个 MarkupToolbar。在您的应用程序中使用多个 MarkupToolbar 是可能的,但您需要注意,它们都将针对并显示 MarkupEditor.selectedWebView 中保存的 MarkupWKWebView 的状态。

自定义文档样式

在 MarkupEditor 底层使用 HTML 的一个很棒的副产品是,您可以使用 CSS 来设置文档的外观样式。要做到这一点,意味着您需要了解一些 CSS 以及 MarkupEditor 的一些内部结构。

MarkupEditor 使用 HTML 元素的子集,并且通常根本不指定 HTML 元素“class”。(唯一的例外是图像以及在您选择图像时显示的关联的调整大小手柄。)MarkupEditor 使用以下 HTML 元素

MarkupEditor 加载两个“基线”CSS 文件。第一个是 mirror.css。此文件中的样式是为了支持 ProseMirror 在编辑期间使用的类。第二个是 markup.css,它用于设置上面标识为 MarkupEditor 支持的元素的样式。有时,markup.css 中的样式会取代 mirror.css 中的样式。

自定义 MarkupEditor 样式的一种方法是 fork 存储库并编辑 markup.css 以满足您的需求。您可以修改 mirror.css,但您真的应该在这样做之前熟悉 ProseMirror。一种侵入性较小的方法是在您的应用程序中包含您自己的 CSS 文件,该应用程序使用 MarkupEditor,并使用 MarkupWKWebViewConfiguration 标识该文件,您可以在实例化 MarkupEditorView 或 MarkupEditorUIView 时传递该文件。您以此方式标识的 CSS 文件在 markup.css之后加载,因此其内容遵循正常的 CSS 级联规则

要指定 MarkupWKWebViewConfiguration,您可以将其保存在您的 MarkupDelegate 中,如 markupConfiguration = MarkupWKWebViewConfiguration()。假设您创建了一个名为 custom.css 的自定义 CSS 文件,并将其打包为应用程序的资源,请使用以下方法在 markupConfiguration 中指定它

markupConfiguration.userCssFile = "custom.css"

这是一个示例,说明如何覆盖 markup.css 中用于 <H4>font-weight: bold

h4 {
    font-weight: normal;
}

CSS 是一种非常强大的自定义工具。markup.css 本身的内容非常少,但向您展示了默认情况下基本元素的样式。如果 MarkupEditor 正在做某些事情来阻止您想要的自定义样式类型,请提交问题;但是,请不要提交关于 CSS 问题的工单。

添加自定义脚本

MarkupEditor 中修改和报告 MarkupWKWebView 中 HTML DOM 状态的功能都包含在 markup.js 中,除非出于调试目的,否则不应直接修改。有关更多详细信息,请参阅 READMEJS

如果您想添加脚本,有两种机制可以执行此操作

  1. 创建一个字符串数组,其中包含有效的 JavaScript 脚本,这些脚本将在 markup.js 之后加载。在实例化时使用 userScripts 参数将这些脚本传递给 MarkupEditorView 或 MarkupEditorUIView。
  2. 创建一个包含您的 JavaScript 代码的文件,并在您的 MarkupWKWebViewConfiguration 中标识该文件。

要指定 MarkupWKWebViewConfiguration,您可以将其保存在您的 MarkupDelegate 中,如 markupConfiguration = MarkupWKWebViewConfiguration()。假设您创建了一个名为 custom.js 的脚本文件,并将其打包为应用程序的资源,请使用以下方法在 markupConfiguration 中指定它

markupConfiguration.userScriptFile = "custom.js"

userScriptFilemarkup.js 之后加载。您的代码可以使用 markup.js 中的函数或您使用 userScripts 加载的函数(如果需要)。

注意: 您的脚本可以访问 DOM,但您将无法在用户脚本中直接修改 DOM。更准确地说:您可以编写修改 DOM 的代码,但您的更改将不会反映在视图本身中,也不会反映在使用 getHtml 检索的文档内容中。这是因为此类更改必须使用 markup.js 中导出的函数或使用从 markup.js 访问的 ProseMirror API 来完成。然而,即使在这一限制下,能够添加脚本仍然非常有用。例如,您可以使用 JavaScript 库将文档内容作为 Markdown 返回。如果您需要直接修改 DOM 或以其他方式与 ProseMirror API 交互,请参阅 READMEJS,了解有关如何构建 MarkupEditor 和修改其中的 JavaScript 的详细信息。

要调用自定义脚本中的函数,您应该扩展 MarkupWKWebView。例如,如果您有一个包含此函数的 custom.js 文件

/**
 * A public method that can be invoked from MarkupWKWebView to return
 * the number of words in the HTML document using a simpleminded approach.
 * Invoking this method requires an extension to MarkupWKWebView.
 */
MU.wordCount = function() {
    let wordCount = 0;
    const styles = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'CODE'];
    for (const style of styles) {
        const elements = document.querySelectorAll(style);
        for (const element of elements) {
            wordCount += element.textContent.trim().split(' ').length;
        }
    };
    return wordCount;
};

那么您可以扩展 MarkupWKWebView 以能够调用 MU.wordCount

extension MarkupWKWebView {
    
    /// Invoke the MU.wordcount method on the JavaScript side that was added-in via custom.js.
    public func wordcount(_ handler: ((Int?)->Void)? = nil) {
        evaluateJavaScript("MU.wordCount()") { result, error in
            if let error {
                print(error.localizedDescription)
            }
            handler?(result as? Int)
        }
    }
    
}

CustomContentView 和 CustomViewController 演示使用此方法以及 custom.css 来修改某些元素的样式并在演示中显示字数统计。这是一个人为设计的用例,但它演示了如何使用自定义脚本和 CSS。

本地图片

能够将图片插入到您正在编辑的文档中是基本功能。在 Markdown 中,您可以通过引用 URL 来做到这一点,并且 URL 可以指向本地文件系统上的文件。当然,MarkupEditor 也可以做到这一点,但是当您将图片插入到即使是最简单的 WYSIWYG 编辑器中的文档时,您通常不必考虑,“嗯,当我移动我的文档时,我必须记住将此文件与我的文档一起复制”或“嗯,我可以将此图片藏在哪里,以便将来可以通过 Internet 访问它。” 从最终用户的角度来看,图片只是文档的一部分。此外,您希望能够将从其他地方复制的图片粘贴到您的文档中。在这种情况下,没有人想考虑创建和跟踪本地文件。

MarkupEditor 将这些图片称为“本地图片”,与驻留在文档外部的图片形成对比。两者都可能很有用!当您插入本地图片(通过从图片工具栏中选择它或将其粘贴到文档中)时,MarkupEditor 会使用 UUID 作为文件名创建一个新的图片文件。默认情况下,该文件与您正在编辑的文本位于同一位置。对于演示,文档 HTML 和本地图片文件保存在从 FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) 找到的 URL 的 id 子目录中。您可以在创建 MarkupWKWebView 时将 id 传递给它 - 例如,它可能是您正在编辑的文档的名称。当 MarkupEditor 创建新的本地图片文件时,您的 MarkupDelegate 会通过 markupImageAdded(url: URL) 方法收到通知,从而为您提供新本地图片的 URL。

虽然本地图片支持在我的情况下是必须的,但似乎一些 MarkupEditor 用户会觉得它矫枉过正,或者希望排除其使用。当您保存文档时,它还需要您对本地图片做一些特殊处理。由于这些原因,有一个选项可以控制是否允许从本地文件选择图片。默认情况下不允许本地图片。要启用它们,请在应用程序生命周期的早期指定 MarkupEditor.allowLocalImages = true。这将在 ImageViewController 中添加一个“选择”按钮。

提醒:MarkupEditor 不知道您希望如何/在哪里保存您正在编辑的文档或您本地添加的图片。这是您的应用程序的责任。

搜索

本节介绍在您使用 MarkupEditor 编辑的文档中进行搜索,但也提供了一些关于搜索您使用 MarkupEditor 创建或编辑的文档的指导。

在文档中搜索

对于许多应用程序,您无需在 MarkupEditor 中搜索您正在编辑的内容。但是当内容变大时,能够找到单词或短语非常方便,就像您在任何文本编辑器中期望的那样。MarkupEditor 通过 MarkupWKWebView 函数支持搜索

func search(
    for text: String,
    direction: FindDirection,
    activate: Bool = false,
    handler: (() -> Void)? = nil
)

FindDirection 是 .forward.backward,指示从文档中的选择点开始搜索的方向。MarkupWKWebView 滚动以使找到的文本可见。

指定 activate: true 以激活“搜索模式”,其中 Enter 被解释为表示“向前搜索下一个匹配项”。(Shift+Enter 向后搜索。)通常,当您在大型文档中搜索时,您只想键入搜索字符串,按 Enter 键,查看选择了什么,然后再次按 Enter 键继续搜索。MarkupEditor 通过在 JavaScript 端捕获 Enter 并在您执行以下操作之一之前将其解释为 searchForward(或 Shift+Enter 为 searchBackward)来支持这种“搜索模式”样式

  1. 您调用 MarkupWKWebView.deactivateSearch(handler:) 来停止拦截 Enter/Shift+Enter,但将搜索状态保留在原位。
  2. 您调用 MarkupWKWebView.cancelSearch(handler:) 来停止拦截 Enter/Shift+Enter 并清除所有搜索状态。
  3. 您单击、触摸或以其他方式键入文档。您的操作会自动禁用 Enter/Shift+Enter 的拦截。

请注意,默认情况下,搜索模式永远不会激活。要激活它,您必须在调用 MarkupWKWebView.search(for:direction:activate:handler:) 时使用 activate: true

SwiftUI 演示(不是 UIKitDemo)包含一个 SearchableContentView,它使用 SearchBardemo.html 上调用搜索。SearchBar 不是 MarkupEditor 库的一部分,因为它很可能大多数用户将以特定于其应用程序的方式实现搜索。例如,您可以在 NavigationStack 上使用 .searchable 修饰符。您可以将 SearchBar 用作一种参考实现,因为它还演示了通过在 SearchBar 的 TextField 中提交文本时指定 activate: true 来使用“搜索模式”。

搜索 MarkupEditor 文档

您可以使用 CoreSpotlight 搜索 MarkupEditor 创建的文档。这是因为 CoreSpotlight 已经知道如何正确处理 HTML 文档。具体来说,这意味着当您在文档中放置表格和图片时,尽管底层 HTML 包含 <table><img> 标签,但索引编制适用于 DOM,因此仅索引文本内容。如果您搜索“table”或“img”,除非有包含单词“table”或“image”的文本元素,否则它不会找到您的文档。

您如何利用 CoreSpotlight?通常,您会拥有某种模型对象,其 contents 包括由 MarkupEditor 生成和编辑的 HTML 文本。您的模型对象可以提供索引功能。这是一个示例(在下面有一些调试打印和 <substitutions>)

/// Add this instance of MyModelObject to the Spotlight index
func index() {
    let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.html)
    attributeSet.kind = "<MyModelObject>"
    let contentData = contents.data(using: .utf8)
    // Set the htmlContentData based on the entire document contents
    attributeSet.htmlContentData = contentData
    if let data = contentData {
        if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) {
            // Put a snippet of content in the contentDescription that will show up in Spotlight searches
            if attributedString.length > 30 {
                attributeSet.contentDescription = "\(attributedString.string.prefix(30))..."
            } else {
                attributeSet.contentDescription = attributedString.string
            }
        }
    }
    // Now create the CSSearchableItem with the attributeSet we just created, using MyModelObject's unique id
    let item = CSSearchableItem(uniqueIdentifier: <MyModelObject's id>, domainIdentifier: <MyModelObject's container domain>, attributeSet: attributeSet)
    item.expirationDate = Date.distantFuture
    CSSearchableIndex.default().indexSearchableItems([item]) { error in
        if let error = error {
            print("Indexing error: \(error.localizedDescription)")
        } else {
            print("Search item successfully indexed!")
        }
    }
}

/// Remove this instance of MyModelObject from the Spotlight index
func deindex() {
    CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: [idString]) { error in
        if let error = error {
            print("Deindexing error: \(error.localizedDescription)")
        } else {
            print("Search item successfully removed!")
        }
    }
}

一旦您索引了您的模型对象,您就可以执行不区分大小写的搜索查询,以查找包含像这样的 text String 的模型对象

let queryString = "domainIdentifier == \'\(<MyModelObject's id)\' && textContent == \"*\(text)*\"c"
searchQuery = CSSearchQuery(queryString: queryString, attributes: nil)
searchQuery?.foundItemsHandler = { items in
    ...
    ... Append contents of items to an array tracking the MyModelObjects that contain text
    ...
}
searchQuery?.completionHandler = { error in
    ...
    ... Do whatever you need afterward, such as additional filtering
    ...
}
// Then run the query
searchQuery?.start()

然后,如果您需要在从 id 取消引用文档后在文档本身中找到 text,您将对包含 contents 的 MarkupWKWebView 使用 在文档中搜索 中的方法。

测试

有一个单独的测试目标,BasicTestsBasicTests 目标涵盖了 MarkupEditor 的所有功能,实际上是可以通过 MarkupToolbar 在各种文本和选择中访问的所有内容。每个执行操作的测试还会测试该操作的撤消和重做,并在每次操作后验证选择。虽然大约有 25 个 XCTest 测试用例,但每个测试用例都执行多个 HTMLTest 实例,以涵盖不同的 HTML 内容和选择。在没有错误的情况下,测试耗时不到 30 秒。

演示

如果您仅使用软件包,则不会获得要构建的演示目标。如果您创建一个包含 MarkupEditor 项目的工作区或仅克隆此存储库,您还将获得两个演示目标,创造性地命名为 SwiftUIDemoUIKitDemo。项目中还有一个 MarkupEditor 框架目标,它 100% 等同于 Swift 软件包。默认情况下,演示都使用框架,因为我发现在早期阶段开发项目时,框架的麻烦要少得多。使用框架和 Swift 软件包之间的唯一区别在于 MarkupWKWebView 在实例化时如何定位和加载其 markup.html 资源。

演示打开 demo.html,其中包含有关最终用户如何使用 MarkupEditor 的信息,并向您展示其功能。他们填充 MarkupToolbar 的 leftToolbar 以包含一个 FileToolbar,该工具栏使您可以创建新文档进行编辑或打开现有的 HTML 文件。DemoContentView(或 UIKitDemo 中的 DemoViewController)既充当 MarkupDelegate 又充当 FileToolbarDelegate。作为 FileToolbarDelegate,它打开一个 TextView 以显示底层原始 HTML,这对于演示来说很好。原始 HTML 会在您键入和更改文档时更新,这很有趣并且对调试很有帮助;但是,您可能不希望在真实应用程序中为每次击键都执行如此繁重的操作。

演示目录还包含 SwiftUI View 和 UIKit UIViewController 的“最简单”版本,因为演示的 DemoContentViewDemoViewController 更复杂,其中包含选择器和 FileToolbar 引入的原始 HTML 显示,以及对选择本地图片的支持。如果您想试用“最简单”版本,只需编辑 SceneDelegate 以指向 SimplestContentViewSimplestViewController

正如 在文档中搜索 部分所述,还提供了一个 SwiftUI SearchableContentView 来演示在 MarkupEditor HTML 文档中搜索的能力,以及一个 SearchBar 来调用该功能。

状态

当前版本是功能完整的 Beta 版。我现在正在我正在开发的另一个项目中自己使用它,因此更改主要由该项目中 MarkupEditor 的使用情况(以及人们可能提出的任何问题)驱动。

已知问题

问题正在 GitHub 上跟踪。

历史

版本 0.8.3 (Beta 7.1)

版本 0.8.0 (Beta 7)

此版本在底层进行了非常大的更改,但应与以前的版本保持(几乎完全)兼容。最大的更改包括用使用 ProseMirror 的代码替换 MarkupEditor 在 markup.js 中的自定义 DOM 操作代码。ProseMirror 是一个 JavaScript“用于构建富文本编辑器的工具包”。MarkupEditor 现在使用 ProseMirror API 来将事务性更改应用于 ProseMirror EditorState,而不是编写 JavaScript 代码来直接操作 contenteditable DOM,ProseMirror EditorState 反过来修改 MarkupWKWebView 中显示的 DOM。我将单独撰写更多关于 ProseMirror 以及 MarkupEditor 如何使用它的文章,但 README 中的这一条目是更改的通知。

版本 0.7.2 (Beta 6)

版本 0.7.1 (Beta 5)

版本 0.7.0 (Beta 4)

此版本的主要更改是采用严格并发,以预期 Swift 6 的到来。您可能需要修改源代码才能使用此版本。具体来说,MarkupEditor 类(其静态成员包含设置和默认值)现在被标记为 @MainActor。如果您从自身未与主 actor 隔离的类中访问 MarkupEditor,例如在设置 MarkupEditor 默认值的方法中,如下所示

private static func initializeMarkupEditor() {
    MarkupEditor.style = .compact
    MarkupEditor.allowLocalImages = true
    MarkupEditor.toolbarLocation = .keyboard
    #if DEBUG
    MarkupEditor.isInspectable = true
    #endif
}

那么您将看到如下错误

Main actor-isolated static property 'style' can not be mutated from a non-isolated context

您可以通过使访问 MarkupEditor 类的方法与主 actor 隔离来修复这些错误,如下所示

@MainActor
private static func initializeMarkupEditor() {
    MarkupEditor.style = .compact
    MarkupEditor.allowLocalImages = true
    MarkupEditor.toolbarLocation = .keyboard
    #if DEBUG
    MarkupEditor.isInspectable = true
    #endif
}

版本 0.6.2 (Beta 3)

版本 0.6.0 (Beta 2)

自 Beta 1 发布以来,已经有很多更改。Beta 2 将它们汇总在一起,我希望更接近于正式的候选版本。

功能

已关闭的问题

版本 0.5.1 (Beta 1)

修复 Swift 包的标记问题。

版本 0.5.0 (Beta 1)

这是 MarkupEditor 的第一个 Beta 版本!请参阅关于它的公告和讨论

已关闭的问题

版本 0.4.0

我认为此版本在功能上已完成,但触摸设备上仍存在一些 UX 问题。如果您之前使用过早期版本,可能会遇到重大更改,但我想在 Beta 版之前完成这些更改。例如,之前的 MarkupWebView 是 MarkupWKWebView 的 UIViewRepresentable。它已被取消,取而代之的是 SwiftUI MarkupEditorView 和单独的 MarkupWKWebViewRepresentable。

Beta 版之前工作的主要驱动因素是可用性和对触摸设备的正确支持。此版本还完全消除了用户了解 SubToolbar 的任何需求,之前的版本由于需要在 MarkupWKWebView 上覆盖它而公开了 SubToolbar。此版本包括新的 MarkupEditorView 和 MarkupEditorUIView,分别用于 SwiftUI 和 UIKit。这些 Views/UIViews 布局和管理 MarkupToolbar 和(新的)MarkupToolbarUIView,当您只想放入一个 View/UIView 时,提供更简单的最终用户体验。下面概述了许多其他改进和功能。

已关闭的问题
可用性

版本 0.3.3

版本 0.3.2

这是一个中间版本,旨在支持多元素操作。多元素操作涉及选择多个元素(例如,段落或格式化文本元素 - 基本上是文档中的大选区)并应用操作,例如更改样式、格式(例如,粗体、斜体)或列表属性。

版本 0.3.1

此版本在 Mac Catalyst 上越来越接近功能完整。与 0.2 版本相比,API 有一些更改,主要是由对本地图片的新支持驱动的。

版本 0.2.2

带标签的工具栏在我的另一个项目中占用了过多的屏幕空间,因此我创建了一个 .compact 形式,并通过 ToolbarPreference 使其可选择。现在我可以轻松添加其他首选项。当我有任意数量的 MarkupWebView 使用单个 MarkupToolbar 进行编辑时,我也需要一种更好的方法来传达 selectionState 和 selectedWebView。我设置了 MarkupEnv 来完成这项工作,并调整了 SwiftUIDemo 和 UIKitDemo 以使用它。

版本 0.2.1

历史和致谢

MarkupEditor 使用了优秀的 ProseMirror 来完成与 WYSIWYG 编辑相关的繁重工作。我不认为当我第一次开始 MarkupEditor 时 ProseMirror 就存在了,但我希望它存在,并且我当时有意识地使用它。如今,ProseMirror 是一款成熟的产品,拥有众多用户、一流的文档以及活跃且乐于助人的 论坛。引用 ProseMirror 网站的话

ProseMirror 是 开源的,您可以合法地将其用于商业用途。然而,编写、维护、支持和为这样的项目设置基础设施需要大量的工作和精力。因此...

如果您使用 ProseMirror 获利,那么社会期望您帮助资助其维护。从这里开始

我鼓励 MarkupEditor 用户认真对待这些声明。MarkupEditor 本身是一个相当大的项目,但通过采用 ProseMirror,为支持该项目而编写的 JavaScript 代码从超过 11,000 行减少到大约 3,000 行,并且诸如撤销/重做之类的最复杂代码完全消失了。

当我开始搜索开源“Swift WYSIWYG 编辑器”时,我发现了一些混杂的东西。RichEditorView 是最引人注目的之一。RichEditorView 最初是使用 UIWebView 构建的,而 UIWebView 早已被弃用。一些人 fork 了它并 移植 到 WKWebView 并分享了他们的工作。我在我正在做的一些工作中使用了一段时间,但我一直遇到边缘情况,感觉我不得不为永远不会真正见天日的 fork 投入大量工作。将结果转移到 SwiftUI 的想法让我感到恶心。MarkupEditor 旨在成为一个适当的“现代”版本,用于您可以在 SwiftUI 或 UIKit 项目中使用的 WYSIWYG 编辑,但它是在最初的 RichEditorView 中孵化出来的。

MarkupEditor 最初直接修改 HTML DOM(包含底层的 contentEditable DIV)的方法看起来是一个好主意,直到您阅读了 Nick Santos 关于 为什么 ContentEditable 很糟糕 的文章。他的主要论点围绕 WYSIWYG 以及以这种方式编辑文档时该术语的含义展开。在最简单的情况下,考虑一下如果您使用 MarkupEditor 保存您编辑的 HTML,然后使用不同的 CSS 在不同的浏览器中显示它。您编辑时看到的内容肯定与您获得的内容不同。当然,文本内容将 100% 相同。如果您正在编辑和保存的内容保持相同的 HTML 形式,并使用相同的 CSS 通过 WKWebView 呈现,那么它将是 WYSIWYG。无论如何,在采用这种方法时,您需要考虑一下。

最初的 MarkupEditor 的优势在于不支持任意 HTML。实际上,它拥有对允许的 HTML 精确子集的定义。(在 ProseMirror 中,这使用其 Schema 进行了形式化。)MarkupEditor 仅针对 WKWebView,因此不存在浏览器可移植性问题。对功能的限制以及 HTML 中缺少样式元素有助于避免 Nick 的文章 中引用的一些问题。此外,通过避免使用(现在已弃用但可能永远存在的)Document.execCommand 来执行针对 DOM 的编辑任务,最初的 MarkupEditor 避免了 WebKit 用 spans 和 styles 污染“干净”的 HTML。这些问题随着 ProseMirror 的出现而消失,因为它的 Schema 准确地捕获了允许哪些元素、它们如何转换为 ProseMirror Nodes 以及它们如何在 DOM 中呈现自身。

如果您认为“管他什么 contentEditable 的废话。构建一个小编辑器有多难?”,我建议您阅读这篇 lord.io 上的文章。DOM 及其令人难以置信且文档完善的 API 已被证明并且非常出色,即使 contenteditable 本身就像一个垃圾堆。能够站在浏览器中完成的工作之上是一种天赐之物,不应该挑剔。

许可证

MarkupEditor 在 MIT 许可证 下可用。

MarkupEditor 依赖于 ProseMirror (https://prosemirror.net, https://github.com/prosemirror)。请注意,如果您分发 MarkupEditor 或将其嵌入到您的应用程序中,您将分发 markup.js,除了原始 MarkupEditor 代码外,它还包含 ProseMirror 的“实质部分”。ProseMirror 也在 MIT 许可证 下可用。