MarkupEditor

repository·main·Indexed 19 days ago

https://github.com/stevengharris/markupeditor

A WYSIWYG text editor for Swift (SwiftUI and UIKit) that utilizes ProseMirror and WKWebView to provide a rich, Markdown-like editing experience. It supports custom CSS styling, JavaScript extensions, and configurable toolbars for iOS and macOS. The editor uses a MarkupCoordinator to handle communication between JavaScript and Swift, requiring explicit calls to getHtml() to retrieve document state.

Tokens
5.5K
Snippets
13
Records
22
Agent score
67%

What's inside MarkupEditor

  1. Handle local images in MarkupEditor

    main

    MarkupEditor supports 'local images'—images that reside on the local file system rather than being external URLs. When a user inserts a local image (via the Image Toolbar or by pasting), the editor creates a new image file with a unique UUID filename.

    Key Behaviors:

    • Default Location: By default, new local images are stored in the same directory as the text being edited.
    • Notification: When a new local image is created, your MarkupDelegate is notified via the markupImageAdded(url: URL) method. You are responsible for managing these files (e.g., saving them alongside your document).
    • Enabling Support: Local image selection is disabled by default. To allow users to select images from their file system, you must set MarkupEditor.allowLocalImages = true early in your application lifecycle.

    On iOS and Mac Catalyst, enabling this adds a 'Select' button to the ImageViewController. On macOS, it adds a 'Select' button to the ImageItem dialog.

    // Enable local image support early in the app lifecycle
    MarkupEditor.allowLocalImages = true
  2. How MarkupEditor works conceptually

    main

    MarkupEditor provides a WYSIWYG editing experience by presenting an HTML document inside a WKWebView.

    • Underlying Engine: It uses the JavaScript library ProseMirror to manage the DOM.
    • Communication: A subclass of WKWebView called MarkupWKWebView communicates between JavaScript and Swift.
    • Coordination: The MarkupCoordinator handles JavaScript callbacks and notifies your implementation of the MarkupDelegate protocol.
    • Data Flow: The editor does not automatically sync the HTML string back to Swift. You must explicitly call MarkupWKWebView.getHtml() to retrieve the current state of the document. This allows you to control when expensive string transformations or saves occur.
  3. Add Custom JavaScript Scripts

    main

    You can extend the editor's functionality by adding custom JavaScript. There are two ways to provide scripts:

    1. Array of Strings: Pass an array of valid JS strings to the userScripts parameter during MarkupEditorView or MarkupEditorUIView instantiation.
    2. External File: Specify a file path in MarkupWKWebViewConfiguration.userScriptFile.

    Important Constraints

    • Module Loading: Scripts are loaded as JavaScript modules. You can import the core API using import { MU } from "./markup-editor.js".
    • DOM Modification: You cannot modify the DOM directly to change the editor state. Changes to the document must be made using the MarkupEditor API or ProseMirror APIs. Direct DOM manipulation will not be reflected in getHtml calls.

    Invoking JS from Swift

    To call a function defined in your custom script from Swift, extend MarkupWKWebView and use executeJavaScript.

    // 1. Define function in custom.js
    import { MU } from "./markup-editor.js"
    
    MU.wordCount = function() {
        const text = MU.activeView()?.state.doc.textContent
        return text ? text.trim().split(/\s+/).filter(Boolean).length : 0
    };
    
    // 2. Extend MarkupWKWebView in Swift to call it
    extension MarkupWKWebView {
        public func wordcount(_ handler: ((Int?) -> Void)? = nil) {
            executeJavaScript("MU.wordCount()") { result, error in
                if let error {
                    print(error.localizedDescription)
                }
                handler?(result as? Int)
            }
        }
    }
  4. Retrieve edited HTML from MarkupWKWebView

    main

    Because the editor does not automatically update your Swift strings, you must manually retrieve the HTML content using the getHtml() method on a MarkupWKWebView instance.

    Best Practices:

    • Avoid calling getHtml() on every keystroke (via MarkupDelegate.markupInput(_:)) as it can make typing feel heavy/laggy.
    • Recommended: Call getHtml() when a user presses a "Save" button, or implement an autosave mechanism that throttles calls to getHtml() after a period of inactivity.
  5. Install the MarkupEditor via Swift Package

    main

    To add MarkupEditor to your Xcode project, use the Swift Package Manager:

    1. Go to File -> Swift Packages -> Add Package Dependency...
    2. Enter the repository URL for MarkupEditor.
    3. Follow the prompts to add it to your target.
  6. Install the MarkupEditor as a Framework

    main

    Alternatively, you can build the framework manually:

    1. Clone the MarkupEditor repository.
    2. Open the project in Xcode and build the MarkupFramework target.
    3. Add the resulting MarkupEditor.framework as a dependency to your project.
  7. Index MarkupEditor documents for CoreSpotlight

    main

    Since MarkupEditor produces HTML, you can use CoreSpotlight to make your documents searchable via system-wide search. CoreSpotlight indexes the DOM text content, meaning it will find text within tables or paragraphs but will not index raw HTML tags like <table> or <img> unless they contain text.

    To index a document, create a CSSearchableItemAttributeSet with a UTType.html content type and populate the htmlContentData with your document's HTML string. It is recommended to also provide a contentDescription snippet for better search results.

    /// 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, 
           let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) {
            // Provide a snippet for the search result description
            if attributedString.length > 30 {
                attributeSet.contentDescription = "\(attributedString.string.prefix(30))..."
            } else {
                attributeSet.contentDescription = attributedString.string
            }
        }
    
        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!")
            }
        }
    }
  8. Explore MarkupEditor Demos

    main

    If you clone the repository or use a workspace containing the project, you can build and run the provided demo targets to see the editor in action:

    • SwiftUIDemo: A SwiftUI-based implementation.
    • UIKitDemo: A UIKit-based implementation.

    Platform Specifics

    iOS and Mac Catalyst

    • The demos use demo.html to showcase capabilities.
    • On iOS, the MarkupToolbar includes a leftToolbar (a FileToolbar) that allows creating new documents or opening existing HTML files.
    • The DemoContentView (SwiftUI) or DemoViewController (UIKit) acts as both the MarkupDelegate and the FileToolbarDelegate.
    • Searching: A SearchableContentView is provided to demonstrate searching within an HTML document using a SearchBar.

    MacOS

    • The MacOS target is only available for the SwiftUI demo.
    • It uses a toolbar from the markupeditor-base project rather than the SwiftUI-based MarkupToolbar.
    • It does not support FileToolbar because file operations (New, Open, Save, Save As) are handled via the standard Mac menubar (File and View menus).
    • The MacOS toolbar includes a Search button by default.

    Simplest Implementations

    For developers who want to see a minimal integration without the complexity of the full demo (which includes pickers and raw HTML displays), the demo directories contain SimplestContentView (SwiftUI) and SimplestViewController (UIKit). You can use these by pointing your SceneDelegate to them.

  9. Install the markupeditor-js project

    main

    To install markupeditor-js, run npm install. This command populates node_modules and automatically executes a prepare script (sh prepare.sh) which copies the runtime dependency markup-editor.js from the markupeditor-base package into the ../MarkupEditor/Resources/ directory of the Swift project.

    Note: If you need to run tests, you must install using a local markupeditor-base development dependency instead of the registry version.

    $ npm install
  10. Debug JavaScript in MarkupWKWebView using Safari Web Inspector

    main

    You can debug the JavaScript running inside the Swift MarkupEditor's MarkupWKWebView using the Safari Web Inspector.

    Prerequisites:

    • Enable the Develop menu in Safari: Settings -> Advanced -> Check Show features for web developers.
    • The Swift MarkupEditor sets isInspectable = true for the MarkupWKWebView in DEBUG builds. This is required for inspection on iOS 16.4+.

    Debugging Steps:

    1. Launch your Swift application (on Mac, iPhone, or Simulator).
    2. In Safari, open the Develop menu, find your device, and select the running app.
    3. The Safari Web Inspector will open, allowing you to view loaded scripts, set breakpoints in markup-editor.js, inspect the DOM, and debug CSS.
    4. If you find a bug, modify the source in the markupeditor-base project, rebuild it, and use npm run prepare in markupeditor-js to sync the fix to the Swift project.
  11. Add Additional Resources to MarkupEditor

    main

    When using userCssFile or userScriptFile, the files are co-located with the document being edited. If you need to include additional assets (like images or other data files) that should be available to the editor's web context, use the userResourceFiles property in MarkupWKWebViewConfiguration.

    Pass an array of filenames that are packaged within your application bundle.

    let markupConfiguration = MarkupWKWebViewConfiguration()
    markupConfiguration.userResourceFiles = ["myImage.png"]
  12. Use a local markupeditor-base dependency for development

    main

    If you are modifying the JavaScript code used by the Swift MarkupEditor, you should not use the npm registry version. Instead, follow these steps to use a local clone of markupeditor-base:

    1. Clone the markupeditor-base repository:
      git clone https://github.com/stevengharris/markupeditor-base.git
    2. In the markupeditor-js directory, install your local clone as a development dependency:
      npm install <path to your cloned markupeditor-base> --save-dev

    Once configured, running the prepare script will copy both the markup-editor.js file and the test data (*.json files) from your local directory into the Swift project's Resources and MarkupEditorTests/BaseTests/Data directories respectively.

    git clone https://github.com/stevengharris/markupeditor-base.git
    npm install <path to your cloned markupeditor-base> --save-dev