Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/stevengharris/markupeditor
WYSIWYG editing for SwiftUI and UIKit apps
https://github.com/stevengharris/markupeditor
swift swiftui uikit wysiwyg
Last synced: 5 days ago
JSON representation
WYSIWYG editing for SwiftUI and UIKit apps
- Host: GitHub
- URL: https://github.com/stevengharris/markupeditor
- Owner: stevengharris
- License: mit
- Created: 2021-03-19T23:44:57.000Z (almost 4 years ago)
- Default Branch: main
- Last Pushed: 2025-01-04T18:55:09.000Z (6 days ago)
- Last Synced: 2025-01-05T11:05:11.771Z (5 days ago)
- Topics: swift, swiftui, uikit, wysiwyg
- Language: Swift
- Homepage:
- Size: 9.85 MB
- Stars: 359
- Watchers: 8
- Forks: 32
- Open Issues: 19
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# MarkupEditor
###
WYSIWYG editing for SwiftUI and UIKit apps.
Jealous of those JavaScript coders with their WYSIWYG text editors, but just can't stomach the idea of immersing yourself in JavaScript when you're enjoying the comfort and joy of Swift? Yeah, me too. So when I was forced to do it, I thought I'd share what I did as a way to help others avoid it.
## Demo
![MarkupEditor](https://user-images.githubusercontent.com/1020361/188996859-0c32da80-151d-4595-8321-9bccd059b1a1.mp4)
## MarkupEditor Goals and Non-Goals
I am working on a larger project that requires embedded support for "rich text" editing. WYSIWYG editing is a must-have requirement for me. I could have forced my developer-users to use Markdown, but I find it to be annoying both to write and to look at while writing. Who wants to have to mentally filter all that cruft on the screen? Sure, it's a lot better than editing raw HTML; but come on, this is the 21st century. Having to deal with an editing experience where you use some kind of "preview mode" to make sure that what you are writing will be presented like you expect feels like CI/CD for writing.
Still, I wanted an editing experience that didn't get in the way. I wanted something with the feature-simplicity of Markdown, but presented in a clean, what-you-see-is-what-you-get manner that supported the basics people expect:
1. Styling
* Present a paragraph or header with a predefined font size
* Bulleted and numbered lists
* Indenting and outdenting of text
2. Formatting
* Bold, italic, underline, code, strikethrough, sub- and super-scripting
3. Embedding
* Images
* Tables
* LinksAs you might expect, then, this feature set is pretty darned close to Markdown - or at least a GitHub flavor of Markdown. And as soon as you do WYSIWYG editing, you must support undo/redo properly. The feature list doesn't include some things you might expect from your favorite word processor:
* Colored text
* Highlighting
* Font size changes (except as implied by identifying something as a paragraph or header)If you want a richer feature set, you can extend the MarkupEditor yourself. The demos include examples of how to extend the MarkupEditor's core features and how to interact with the file system for selecting what to edit. It's my intent to keep the core MarkupEditor feature set to be similar to what you will see in GitHub Markdown.
### What is WYSIWYG, Really?
The MarkupEditor is presenting an HTML document to you as you edit. It uses JavaScript to change the underlying DOM and calls back into Swift as you interact with the document. The MarkupEditor does not know how to save your document or transform it to some other format. This is something your application that consumes the MarkupEditor will need to do. The MarkupEditor will let your `MarkupDelegate` know as the underlying document changes state, and you can take advantage of those notifications to save and potentially transform the HTML into another form. If you're going to do that, then you should make sure that round-tripping back into HTML also works flawlessly. Otherwise, you are using a "What You See Is Not What You Get" editor, which is both less pronounceable and much less useful to your end users.
## Installing the MarkupEditor
You can install the Swift package into your project, or you can build the MarkupEditor framework yourself and make that a dependency.
### Swift PackageAdd the `MarkupEditor` package to your Xcode project using File -> Swift Packages -> Add Package Dependency...
### Framework
Clone this repository and build the MarkupFramework target in Xcode. Add the MarkupEditor.framework as a dependency to your project.
## Using the MarkupEditor
Behind the scenes, the MarkupEditor interacts with an HTML document (created in `markup.html`) that uses a single `contentEditable` DIV element to modify the DOM of the document you are editing. It uses a subclass of `WKWebView` - the `MarkupWKWebView` - to make calls to the JavaScript in `markup.js`. In turn, the JavaScript calls back into Swift to let the Swift side know that changes occurred. The callbacks on the Swift side are handled by the `MarkupCoordinator`. The `MarkupCoordinator` is the `WKScriptMessageHandler` for a single `MarkupWKWebView` and receives all the JavaScript callbacks in `userContentController(_:didReceive:)`. The `MarkupCoordinator` in turn notifies your `MarkupDelegate` of changes. See `MarkupDelegate.swift` for the full protocol and default implementations.
That sounds complicated, but it is mostly implementation details you should not need to worry about. Your app will typically use either the `MarkupEditorView` for SwiftUI or the `MarkupEditorUIView` for UIKit. The `MarkupDelegate` protocol is the key mechanism for your app to find out about changes as the user interacts with the document. You will typically let your main SwiftUI ContentView or your UIKit UIViewController be your `MarkupDelegate`. You can customize the behavior of the MarkupEditor using the `MarkupEditor` struct (e.g., `MarkupEditor.toolbarStyle = .compact`).
The `MarkupToolbar` is a convenient, pre-built UI to invoke changes to the document by interacting with the `MarkupWKWebView`. You don't need to use it, but if you do, then the easiest way to set it up is just to let the `MarkupEditorView` or `MarkupEditorUIView` handle it automatically. Your application may require something different with the toolbar than what the `MarkupEditorView` or `MarkupEditorUIView` provides. For example, you might have multiple `MarkupEditorViews` that need to share a single `MarkupToolbar`. In this case, you should specify `MarkupEditor.toolbarPosition = .none`. Then, for SwiftUI, use the `MarkupEditorView` together with the `MarkupToolbar` as standard SwiftUI views, identifying the `MarkupEditor.selectedWebView` by responding to the `markupTookFocus(_:)` callback in your `MarkupDelegate`. For UIKit, you can use the `MarkupEditorUIView` and `MarkupToolbarUIView`. See the code in the `MarkupEditorView` or `MarkupEditorUIView` for details.
To avoid spurious logging from the underlying `WKWebView` in the Xcode console, you can set `OS_ACTIVITY_MODE` to `disable` in the Run properties for your target. However, this has the side-effect of removing OSLog messages from the MarkupEditor from showing up, too, and is probably not a good idea in general.
### SwiftUI Usage
In the simplest case, just use the `MarkupEditorView` like you would any other SwiftUI view. By default, on all but phone devices, it will place a `MarkupToolbar` above a `UIViewRepresentable` that contains the `MarkupWKWebView`, which is where you do your editing. On phone devices, it will make the toolbar the `inputAccessoryView` for the `MarkupWKWebView`, giving you access to the toolbar when the keyboard shows up. Your ContentView can act as the `MarkupDelegate`, which is almost certainly what you want to do in all but the simplest applications. The `MarkupEditorView` acts as the `MarkupDelegate` if you don't specify one yourself.
```
import SwiftUI
import MarkupEditorstruct SimplestContentView: View {
@State private var demoHtml: String = "Hello World
"
var body: some View {
MarkupEditorView(html: $demoHtml)
}
}
```### UIKit Usage
In the simplest case, just use the `MarkupEditorUIView` like you would any other UIKit view. By default, on all but phone devices, it will place a `MarkupToolbarUIView` above a `MarkupWKWebView`, which is where you do your editing. On phone devices, it will make the toolbar the `inputAccessoryView` for the `MarkupWKWebView`, giving you access to the toolbar when the keyboard shows up. Your ViewController can act as the `MarkupDelegate`, which is almost certainly what you want to do in all but the simplest applications. The `MarkupEditorUIView` acts as the `MarkupDelegate` if you don't specify one yourself.
```
import UIKit
import MarkupEditorclass SimplestViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let markupEditorUIView = MarkupEditorUIView(html: "Hello World
")
markupEditorUIView.frame = view.frame
markupEditorUIView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(markupEditorUIView)
}
}
```### Getting Edited HTML
As you edit your document, you can see its contents change in proper WYSIWYG fashion. The document HTML is *not* automatically passed back to Swift as you make changes. You must retrieve the HTML at an appropriate place in your app using `MarkupWKWebView.getHtml()`. This leaves the question: what is "an appropriate place"? The answer is dependent on how you are using the MarkupEditor. In the demo, where you can display the HTML as you type, the HTML is retrieved at every keystroke by using the `MarkupDelegate.markupInput(_:)` method. This is generally going to be a bad idea, since it makes typing much more heavyweight than it should be. You might only retrieve the edited HTML when your user presses a "Save" button. You might want to implement an autosave type of approach by tracking when changes are happening using `MarkupDelegate.markupInput(_:)`, and only invoking `MarkupWKWebView.getHtml()` when enough time has passed.
The `getHtml()` method needs to be invoked on a MarkupWKWebView instance. Generally you will need to hold onto that instance yourself in your MarkupDelegate. You can get access to it in almost all of the MarkupDelegate methods (e.g., `MarkupWKWebView.markupLoaded` or `MarkupWKWebView.markupInput`). Using `MarkupEditor.selectedWebView` to get the instance will not be reliable, because the value becomes nil when no MarkupWKWebView has focus.
Note that in SwiftUI, when you pass HTML to the MarkupEditorView, you pass a binding to a String. For example:
```
@State private var demoHtml: String = "Hello World
"
var body: some View {
MarkupEditorView(html: $demoHtml)
}
```In this example, `demoHtml` is **not** modified by the MarkupEditor as you edit the document. The `html` is passed as a binding so that you can modify it from your app. You must use `MarkupWKWebView.getHtml()` to get the modified HTML.
## Customizing the MarkupEditor
You can do some limited customization of the MarkupToolbar and the MarkupEditor behavior overall. You should always do these customizations early in your app lifecycle.
You can also provide your own CSS-based style customization and JavaScript scripts for the MarkupEditor to use in your app. The StyledContentView and StyledViewController demonstrate usage of custom CSS and scripts on the `demo.html` document and are discussed below.
### Customizing the Toolbar
You can use either a compact style of toolbar with only buttons, or a labeled form that shows what each button does. The default style is labeled. If you want to use the compact form, set `MarkupEditor.style` to `.compact`.
You can customize the various toolbars by eliminating them and/or subsetting their contents. You do this by creating a new instance of `ToolbarContents` and assigning it to `ToolbarContents.custom`. The `MarkupMenu` also uses the `ToolbarContents` to customize what it holds, so it's important to have set `ToolbarContents.custom` *before* creating the `MarkupMenu`. An easy way to do that is to set it up in your `AppDelegate` by overriding `init()`. Here is an example that adds the `CorrectionToolbar` (that holds the `Undo` and `Redo` buttons and is off by default) and only includes Bold, Italic, and Underline as formats in the FormatToolbar. It also sets up to use the compact style and to allow local images (as discussed below):
```
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
}
```Note that the MarkupToolbar uses the static value `MarkupEditor.selectedWebView` to determine which MarkupWKWebView to invoke operations on. This means that generally you should only have a single MarkupToolbar. It's possible to use multiple MarkupToolbars in your app, but you need to be aware that they will each operate against and display the state of the MarkupWKWebView held in `MarkupEditor.selectedWebView`.
### Customizing Document Style
A great byproduct of using HTML under the covers of the MarkupEditor is that you can use CSS to style the way the document looks. To do so means you need to know something about CSS and a bit about the internals of the MarkupEditor.
The MarkupEditor uses a subset of HTML elements and generally does not specify the HTML element "class" at all. (The one exception is for images and the associated resizing handles that are displayed when you select an image.) The MarkupEditor uses the following HTML elements:
* Paragraph Styles: `
`, `
`, `
`, `
`, `
`, `
`, `
`. `
` is the default style, also referred to as "Normal" in various places.
* Formatting: ``, ``, ``, ``, `
`.
* Images: ``. The internal details of the styling and classes to support resizable images are in `markup.js` but will not be covered here.
* Links: ``.
* Lists: ``, `
`, `
- `.
* Tables: ``, ``, ``, ``, ``, ``.
* Indenting: ``.All editable content is contained in a single `
` with the id of `editor`. Occasionally a `
` element will be used to enable selection within an empty element. For example, if you hit Enter, the MarkupEditor produces a new paragraph as ``. `` elements are used for the image resizing handles but are never returned in HTML when you use `MarkupWKWebView.getHtml()`
The MarkupEditor uses a "baseline" styling that is provided in `markup.css`. One way to customize the MarkupEditor style is to fork the repository and edit `markup.css` to fit your needs. A less intrusive mechanism is to include your own CSS file with your app that uses the MarkupEditor, and identify the file using `MarkupWKWebViewConfiguration` that you can pass when you instantiate a MarkupEditorView or MarkupEditorUIView. The CSS file you identify this way is loaded *after* `markup.css`, so its contents follows the normal [CSS cascading rules](https://russmaxdesign.github.io/maxdesign-slides/02-css/207-css-cascade.html#/).
To specify the MarkupWKWebViewConfiguration, you might hold onto it in your MarkupDelegate as `markupConfiguration = MarkupWKWebViewConfiguration()`. Assuming you created a custom CSS file called `custom.css` and packaged it as a resource with your app, specify it in the `markupConfiguration` using:
```
markupConfiguration.userCssFile = "custom.css"
```Here is an example of how to override the `font-weight: bold` used for `
` in `markup.css`:
```
h4 {
font-weight: normal;
}
```Here is an example showing how to modify the caret and selection colors (blue by default), with special behavior for dark mode:
```
#editor {
caret-color: black;
}
@media (prefers-color-scheme: dark) {
#editor {
caret-color: yellow;
}
}
```CSS is an incredibly powerful tool for customization. The contents of `markup.css` itself are minimal but show you how the basic elements are styled by default. If there is something the MarkupEditor is doing to prevent the kind of custom styling you are after, please file an issue; however, please do not file issues with questions about CSS.
### Adding Custom Scripts
MarkupEditor functionality that modifies and reports on the state of the HTML DOM in the MarkupWKWebView is all contained in `markup.js`. If you have scripting you want to add, there are two mechanisms for doing so:
1. Create an array of strings that contain valid JavaScript scripts that will be loaded after `markup.js`. Pass these scripts to the MarkupEditorView or MarkupEditorUIView using the `userScripts` parameter at instantiation time.
2. Create a file containing your JavaScript code, and identify the file in your MarkupWKWebViewConfiguration.To specify the MarkupWKWebViewConfiguration, you might hold onto it in your MarkupDelegate as `markupConfiguration = MarkupWKWebViewConfiguration()`. Assuming you created a script file called `custom.js` and packaged it as a resource with your app, specify it in the `markupConfiguration` using:
```
markupConfiguration.userScriptFile = "custom.js"
```The `userScriptFile` is loaded after `markup.js`. Your code can use the functions in `markup.js` or which you loaded using `userScripts` if needed.
To invoke a function in your custom script, you should extend the MarkupWKWebView. For example, if you have a `custom.js` file that contains this function:
```
/**
* A public method that can be invoked from MarkupWKWebView to execute the
* assignment of classes to h1 and h2 elements, so that custom.css styling
* will show up. Invoking this method requires an extension to MarkupWKWebView
* which can be called from the MarkupDelegate.markupLoaded method.
*/
MU.assignClasses = function() {
const h1Elements = document.getElementsByTagName('h1');
for (let i = 0; i < h1Elements.length; i++) {
element = h1Elements[i];
element.classList.add('title');
};
const h2Elements = document.getElementsByTagName('h2');
for (let i = 0; i < h2Elements.length; i++) {
element = h2Elements[i];
element.classList.add('subtitle');
};
};
```then you can extend MarkupWKWebView to be able to invoke `MU.assignClasses`:
```
extension MarkupWKWebView {
/// Invoke the MU.assignClasses method on the JavaScript side that was added-in via custom.js.
public func assignClasses(_ handler: (()->Void)? = nil) {
evaluateJavaScript("MU.assignClasses()") { result, error in
if let error {
print(error.localizedDescription)
}
handler?()
}
}
}
```The StyledContentView and StyledViewController demos use this approach along with `custom.css` to set the `title` class on `H1` elements, and `subtitle` class on `H2` elements and apply styling to them. This is a contrived use case (you could just use `custom.css` to style `H1` and `H2` directly), but it shows both custom scripting and CSS being used.
## Local Images
Being able to insert an image into a document you are editing is fundamental. In Markdown, you do this by referencing a URL, and the URL can point to a file on your local file system. The MarkupEditor can do the same, of course, but when you insert an image into a document in even the simplest WYSIWYG editor, you don't normally have to think, "Hmm, I'll have to remember to copy this file around with my document when I move my document" or "Hmm, where can I stash this image so it will be accessible across the Internet in the future." From an end-user perspective, the image is just part of the document. Furthermore, you expect to be able to paste images into your document that you copied from elsewhere. Nobody wants to think about creating and tracking a local file in that case.
The MarkUpEditor refers to these images as "local images", in contrast to images that reside external to the document. Both can be useful! When you insert a local image (by selecting it from the Image Toolbar or by pasting it into the document), the MarkupEditor creates a _new_ image file using a UUID for the file name. By default, that file resides in the same location as the text you are editing. For the demos, the document HTML and local image files are held in an `id` subdirectory of the URL found from `FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)`. You can pass the `id` to your `MarkupWKWebView` when you create it - for example, it might be the name of the document you're editing. When the MarkupEditor creates a new local image file, your `MarkupDelegate` receives a notification via the `markupImageAdded(url: URL)` method, giving you the URL of the new local image.
Although local image support was a must-have in my case, it seems likely some MarkupEditor consumers would feel like it's overkill or would like to preclude its use. It also requires you to do something special with the local images when you save your document. For these reasons, there is an option to control whether to allow selection of images from local files. Local images are disallowed by default. To enable them, specify `MarkupEditor.allowLocalImages = true` early in your application lifecycle. This will add a Select button to the Image Toolbar.
A reminder: The MarkupEditor does not know how/where you want to save the document you're editing or the images you have added locally. This is the responsibility of your app.
## Search
This section addresses searching within the document you are editing using the MarkupEditor but also provides some guidance on searching for the documents you create or edit using MarkupEditor.
### Searching Within A Document
For many applications, you will have no need to search the content you are editing in the MarkupEditor. But when content gets larger, it's very handy to be able to find a word or phrase, just like you would expect in any text editor. The MarkupEditor supports search with the function:
```
func search(
for text: String,
direction: FindDirection,
activate: Bool = false,
handler: (() -> Void)? = nil
)
```The FindDirection is either `.forward` or `.backward`, indicating the direction to search from the selection point in the document. The MarkupWKWebView scrolls to make the text that was found visible.
Specify `activate: true` to activate a "search mode" where Enter is interpreted as meaning "search for the next occurrence in the forward direction". (Shift+Enter searches backward.) Often when you are searching in a large document, you want to just type the search string, hit Enter, see what was selected, and hit Enter again to continue searching. This "search mode" style is supported in the MarkupEditor by capturing Enter on the JavaScript side and interpreting it as `searchForward` (or Shift+Enter for `searchBackward`) until you do one of the following:
1. You invoke `MarkupWKWebView.deactivateSearch(handler:)` to stop intercepting Enter/Shift+Enter, but leaving the search state in place.
2. You invoke `MarkupWKWebView.cancelSearch(handler:)` to stop intercepting Enter/Shift+Enter and clear all search state.
3. You click-on, touch, or otherwise type into the document. Your action automatically disables intercepting of Enter/Shift+Enter.Note that by default, search mode is never activated. To activate it, you must use `activate: true` in your call to `MarkupWKWebView.search(for:direction:activate:handler:)`.
The SwiftUI demo includes a `SearchableContentView` that uses a `SearchBar` to invoke search on `demo.html`. The `SearchBar` is not part of the MarkupEditor library, since it's likely most users will implement search in a way that is specific to their app. For example, you might use the `.searchable` modifier on a NavigationStack. You can use the `SearchBar` as a kind of reference implementation, since it also demonstrates the use of "search mode" by specifying `activate: true` when you submit text in the `SearchBar's` TextField.
### Searching for MarkupEditor Documents
You can use CoreSpotlight to search for documents created by the MarkupEditor. That's because CoreSpotlight already knows how to deal properly with HTML documents. To be specific, this means that when you put a table and image in your document, although the underlying HTML contains `` and `` tags, the indexing works on the DOM and therefore only indexes the text content. If you search for "table" or "image", it won't find your document unless there is a text element containing the word "table" or "image".
How might you make use of CoreSpotlight? Typically you would have some kind of model object whose `contents` includes the HTML text produced-by and edited-using the MarkupEditor. Your model objects can provide indexing functionality. Here is an example (with some debug printing and \ below):
```
/// Add this instance of MyModelObject to the Spotlight index
func index() {
let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.html)
attributeSet.kind = ""
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: , domainIdentifier: , 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!")
}
}
}
```Once you have indexed your model objects, you can then execute a case-insensitive search query to locate model objects that include a `text` String like this:
```
let queryString = "domainIdentifier == \'\(