Coder Social home page Coder Social logo

facebook / lexical-ios Goto Github PK

View Code? Open in Web Editor NEW
457.0 14.0 23.0 887 KB

Lexical iOS is an extensible text editor framework that integrates the APIs and philosophies from Lexical Web with a Swift API built on top of TextKit.

License: MIT License

Swift 100.00%

lexical-ios's Introduction

Lexical iOS

An extensible text editor/renderer written in Swift, built on top of TextKit, and sharing a philosophy and API with Lexical JavaScript.

Status

Lexical iOS is used in multiple apps at Meta, including rendering feed posts that contain inline images in Workplace iOS.

Lexical iOS is in pre-release with no guarantee of support.

For changes between versions, see the Lexical iOS Changelog.

Playground

We have a sample playground app demonstrating some of Lexical's features:

Screenshot of playground app

The playground app contains the code for a rich text toolbar. While this is not specifically a reusable toolbar that you can drop straight into your projects, its code should provide a good starting point for you to customise.

This playground app is very new, and many more features will come in time!

Requirements

Lexical iOS is written in Swift, and targets iOS 13 and above. (Note that the Playground app requires at least iOS 14, due to use of UIKit features such as UIMenu.)

Building Lexical

We provide a Swift package file that is sufficient to build Lexical core. Add this as a dependency of your app to use Lexical.

Some plugins included in this repository do not yet have package files. (This is because we use a different build system internally at Meta. Adding these would be an easy PR if you want to start contributing to Lexical!)

Using Lexical in your app

For editable text with Lexical, instantiate a LexicalView. To configure it with plugins and a theme, you can create an EditorConfig to pass in to the LexicalView's initialiser.

To programatically work with the data within your LexicalView, you need access to the Editor. You can then call editor.update {}, and inside that closure you can use the Lexical API.

For more information, see the documentation.

Full documentation

Read the Lexical iOS documentation.

Join the Lexical community

Join us at our Discord server, where you can talk with the Lexical team and other users.

See the CONTRIBUTING file for how to help out.

Tests

Lexical has a suite of unit tests, in XCTest format, which can be run from within Xcode. We do not currently have any end-to-end tests.

License

Lexical is MIT licensed.

lexical-ios's People

Contributors

amyworrall avatar damirstuhec avatar facebook-github-bot avatar mansimransingh avatar patelneal avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

lexical-ios's Issues

Refactor undo/redo and integrate with playground

Lexical has an implementation of undo/redo, in History.swift. (Arguably this should have been in a Plugin, but there's some historical cruft here.)

Currently, to integrate history into a surface, you need to:

  • register handlers for the commands undo, redo, and clearEditor, and in the handlers, proxy those commands to lexicalView.editorHistory
  • create toolbar buttons to dispatch the commands undo and redo
  • register handlers for canUndo and canRedo, and use these handlers to trigger updating the toolbar to enable/disable the undo/redo buttons
  • register an update listener, that calls applyChange() on the EditorHistory instance, passing the active and previous editor states

I would like to see this improved. We should have a new EditorHistoryPlugin class, in its own package, which will:

  • own and expose the EditorHistory object
  • creates and keeps a reference to externalHistoryState, which is passed in to EditorHistory on init
  • register the handlers for undo, redo, and clearEditor
    • In fact, you could move the declarations of the constants for the .undo and .redo commands into EditorHistoryPlugin as well
  • registers the update listener that calls applyChange() on the EditorHistory object
  • expose getters for canUndo and canRedo, which just check if externalHistoryState.undoStack > 0 (and same for redoStack). Note that this doesn't obviate the need for the canUndo and canRedo commands -- those are still useful to know when these values change.
  • move History.swift into this package

Then, to integrate it into the playground:

  • It should be enough to make the toolbar buttons just dispatch the undo and redo commands, now that the EditorHistoryPlugin is listening for them.
  • Still register canUndo and canRedo, and use them to trigger a toolbar update.

Note, I've tagged this task good first issue, although there are quite a lot of moving parts. If you need any more context or help, let me know!

Port NodeSelection from Lexical JS

Lexical iOS has fairly good support for decorator nodes (which on iOS are surfaced as embedded UIViews). However, so far we've only used them in products that use Lexical in read-only mode.

In order to better support decorators in editable mode, we need support for the NodeSelection selection type (in addition to the RangeSelection).

Anyone interested in taking on this task, please reach out to me -- it requires a good knowledge of Lexical's architecture, but would be very impactful.

Crash when typing in a lexical view which has decorators: Terminating app due to uncaught exception 'NSInternalInconsistencyException'

After inserting a custom decorator into the document and then typing more, I get the following crash:

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: '-[Lexical.LayoutManager _fillLayoutHoleForCharacterRange:desiredNumberOfLines:isSoft:] *** attempted layout while textStorage is editing. It is not valid to cause the layoutManager to do layout while the textStorage is editing (ie the textStorage has been sent a beginEditing message without a matching endEditing.)'

The crash is on line 544 of Editor:
frontend?.layoutManager.invalidateLayout(forCharacterRange: rangeCacheItem.range, actualCharacterRange: nil)

This makes me very nervous to use the library in production, any advice on how to avoid this crash?

Here's the JSON of my lexical right before the crash (I was printing after every edit):

{
  "root": {
    "direction": null,
    "format": "",
    "indent": 0,
    "type": "root",
    "children": [
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "Test  niceee ",
            "version": 1,
            "type": "text"
          },
          {
            "type": "timestamp",
            "version": 1,
            "timestamp": 34.43114
          },
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": " ",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "New line fdsa ",
            "version": 1,
            "type": "text"
          },
          {
            "type": "timestamp",
            "version": 1,
            "timestamp": 34.43114
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "E",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "Weeeeee",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "Weeee",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "Da",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "Test ",
            "version": 1,
            "type": "text"
          },
          {
            "type": "timestamp",
            "version": 1,
            "timestamp": 34.43114
          },
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": " ",
            "version": 1,
            "type": "text"
          },
          {
            "format": 1,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "tello",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "F",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      },
      {
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "children": [
          {
            "format": 0,
            "detail": 0,
            "style": "",
            "mode": "normal",
            "text": "F",
            "version": 1,
            "type": "text"
          }
        ],
        "version": 1
      }
    ],
    "version": 1
  }
}

Memory leaks

Hi - I'm not sure if im doing something wrong, or this is a known issue (altho i couldn't find any references to this in PRs or issues).

There are memory leaks caused by internal circular references. I thought was a SwiftUI weirdness, but even a simplest UIKit app leads to leaked Editor and associated objects.

Here's a minimally reproducible example:
https://github.com/mansimransingh/lexical-ios/tree/memory-leak-test

It is the example app, which pushes and pops the ViewController, leaking an Editor each time.

I'm going to try dig into this to resolve it.

But was wondering if am expected to do manual cleanup or something, or if this is a known issue.

Thoughts?

Document ElementNode

Document all functions within ElementNode. (You can copy descriptions from the Lexical JS docs for anything where it makes sense to do so.)

Then make a markdown file to categorise the methods into topics. (See Node.md for an example how to do this. It uses Apple's DocC format.)

Numeric lists

I recently ported the list logic to iOS (in #4). As part of doing that work, I exposed a button to create bulleted lists in the Playground.

We also need numeric lists. The first step will be adding it to the paragraph style menu in the playground app (in ToolbarPlugin.swift), and registering the relevant insertOrderedList command within ListPlugin.swift.

Next, you'll need to change the internal logic of list items for how they generate their numbers. Currently, in ListItemNode's getAttributedStringAttributes() function, they derive their number by counting their previous siblings. However, we don't want that -- we want to fetch the value with self.getValue().

Finally, test that everything is working. A list item's value is updated in the updateChildrenListItemValue() function in ListStyleEvents.swift -- I ported this directly from Lexical JS, along with various functions that call it. But since I've only exposed bulleted lists in the UI of the playground so far, I haven't tested how well this works! Do some manual testing in the playground, and preferably also write a unit test that covers numbered list values.

Crash when creating a link

I'm experimenting with the latest version of LexicalPlayground and running into a crash when applying a link to the selected text. Details below. Please let me know if you need more info.

Steps to reproduce

  1. Type "Hello world".
  2. Select "world".
  3. Tap the link button in the top toolbar.
  4. Write any valid URL.
  5. Tap "Done" to apply the link.
  6. Crash. (in TextStorage.swift)๐Ÿ’ฅ

Assets

Screenshot 2023-09-20 at 15 19 13
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2023-09-20.at.15.22.43.mp4

Add embeddable images to the playground

This may or may not rely on task #15, the experience may be bad without it!

Add a way to choose an image to embed within the playground.

You can use LexicalInlineImagePlugin as a starting point, but you may have to modify its ImageNode, especially if you want to support embedding local images from the phone's camera roll. (Currently LexicalInlineImagePlugin only supports images via URL.)

I'm not tagging this task as "good first issue", because it requires learning a lot about our decorator architecture, and it also requires working out which bits of the decorator architecture are currently missing or not working well in editable mode. (It would be appropriate to file issues for the missing/broken bits, you don't have to fix everything yourself as part of this task!)

Text node handle "style" field

On Lexical JS, text nodes have a "style" field, into which raw CSS can be put. (see TextNode.ts).

On iOS, so far we have implemented this field as a string field, but it does not do anything to affect the output. (The field is there in order that we can correctly round-trip some JSON created by Lexical JS, but nothing more.)

We should decide how to address this in iOS. A most idiomatic iOS way of handling a "style" field, for example, would be to make it take arbitrary NSAttributedString.Key/value pairs. That would accomplish the same thing on iOS as the CSS-based "style" field on web -- letting the API consumer add whatever styling information they wanted, without Lexical having to understand it. However, then we would not get cross platform compatibility of serialised JSON.

We could have our own manual parser/mapping of CSS properties to attributed string properties, built in to Lexical iOS. This will by definition be only a partial solution, as supporting every single possible use of CSS there would be super difficult.

We could punt the problem to the user, by having iOS Lexical store styling info as NSAttributedString.Key/value pairs, but allowing API users to register some kind of plugin/transform that runs when turning the text nodes into JSON and vice versa. Then users can write something which parses just the styling information that they plan to use on their web version, without having to worry about a general purpose solution.

We could also modify Lexical JS, and either come up with an interim format for styling info, or limit the "style" field to a subset of possibilities. However that has the disadvantage of limiting style's usefulness as an 'escape hatch' for any kind of styling not supported natively by Lexical.

We could make the iOS styling field completely separate from the web styling field, and have both versions of Lexical round-trip both of them. That way, there'd be no data loss, each platform would be able to add its own styling information, but there would be no sharing of styling information unless API consumers built it manually.

All of these possible approaches have tradeoffs. Suggestions are welcome!

Links

We want to be able to insert, edit, remove and visit links within the text. Here's what's needed:

  • The link plugin currently registers one command, .link, which adds a link. However, it would be much better to follow the behaviour of Lexical JS, which registers TOGGLE_LINK_COMMAND (see packages/lexical-link/src/index.ts). The logic for this has already been ported (in the toggleLink() function), although it is probably worth checking if it has been ported accurately. Then you'll need to register the new command.
  • Add the link plugin as a dependency of the playground.
  • Add a toolbar button to the playground for links.
  • Within the playground's ToolbarPlugin, build a simple UI for when the link button is pressed. At a suggestion, if there's no link already present, you'll want to present a UIAlert with a text field in it, prompting for the URL to add. If there is a link present, you'll want to remove it.

For handling what happens if a link is tapped on inside the editor, I'm going to file a separate task.

Link taps as Lexical commands

Issue #8 involves building a UI for creating links within the playground.

This task is about handling what happens when a link is tapped within the editor.

Currently, LexicalView has a LexicalViewDelegate method, textView(_, shouldInteractWith:, in:, interatction:). This method is essentially proxying a UITextView delegate method. (Note that TextView is our UITextView subclass, and LexicalView is a wrapper view around this, so there's a couple of layers here.)

We don't want to use delegate methods as ways of extending Lexical, since Lexical is best extended by means of registering commands. However, this code was written long ago, before we used commands for everything!

Ideal behaviour

There's already a .linkTapped command, and we use it in LexicalReadOnlyView. We want to make these changes:

  • Make LexicalView dispatch the .linkTapped command, rather than calling a delegate method.
  • Add a default implementation for .linkTapped within Lexical core, that just hands the URL to UIApplication to open. (Users of Lexical can register their own handler at a higher priority if they want different behaviour.)

Cannot run playground project

Open the playground project and run it, it shows 2 errors

lexical-ios/Playground/LexicalPlayground/ToolbarPlugin.swift:121:11 Cannot find 'findMatchingParent' in scope

lexical-ios/Playground/LexicalPlayground/ToolbarPlugin.swift:188:9 Cannot find 'setBlocksType' in scope

Looks like these 2 function is not exposed to public, I guess ๐Ÿค”

Lexical does not render images when setting editorState

I currently have this code to render the LexicalView:

        let theme = Theme()
        let editorConfig = EditorConfig(theme: theme, plugins: [InlineImagePlugin()])
        let lexicalView = LexicalView(editorConfig: editorConfig, featureFlags: FeatureFlags())
        
        let editor = lexicalView.editor
        
       if let newEditorState = try? EditorState.fromJSON(json: jsonString, editor: editor) {
            try? editor.setEditorState(newEditorState)
        }

the value for jsonString used above is: "{\"root\":{\"children\":[{\"children\":[{\"src\":\"https://t4.ftcdn.net/jpg/01/43/42/83/360_F_143428338_gcxw3Jcd0tJpkvvb53pfEztwtU9sxsgT.jpg\",\"maxWidth\":\"100%\",\"maxHeight\":800,\"width\":10,\"height\":20,\"type\":\"image\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}"

I expect the image to render in the lexical view, but it just shows empty.

In order to render any image in LexicalView I have to run this:

        try? editor.update {
            let imageNode = ImageNode(url: "https://t4.ftcdn.net/jpg/01/43/42/83/360_F_143428338_gcxw3Jcd0tJpkvvb53pfEztwtU9sxsgT.jpg", size: CGSize(width: 300, height: 300), sourceID: "")
              if let selection = try getSelection() {
                _ = try selection.insertNodes(nodes: [imageNode], selectStart: false)
              }
        }

The problem with this approach is that it renders the inline image functionality worthless when I have to manually insert images.

Is this expected behavior? Or am I using it wrong?

For reference this is the entire class (it's a SwiftUI project so it is in a UIViewRepresentable):

import Foundation
import Lexical
import SwiftUI
import LexicalInlineImagePlugin

struct LexicalReadOnlyViewRepresentable: UIViewRepresentable {
    @State var jsonString: String
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
    }
    
    func makeUIView(context: Context) -> some UIView {
        let theme = Theme()
        let editorConfig = EditorConfig(theme: theme, plugins: [InlineImagePlugin()])
        let lexicalView = LexicalView(editorConfig: editorConfig, featureFlags: FeatureFlags())
        
        let editor = lexicalView.editor
        
       if let newEditorState = try? EditorState.fromJSON(json: jsonString, editor: editor) {
            try? editor.setEditorState(newEditorState)
        }
        
       // have to write this code for any images to show up
        try? editor.update {
           
            let imageNode = ImageNode(url: "https://t4.ftcdn.net/jpg/01/43/42/83/360_F_143428338_gcxw3Jcd0tJpkvvb53pfEztwtU9sxsgT.jpg", size: CGSize(width: 300, height: 300), sourceID: "")
              if let selection = try getSelection() {
                _ = try selection.insertNodes(nodes: [imageNode], selectStart: false)
              }
        }
        
        
        return lexicalView
    }
}

Ability to handle paste for images in editor

It would be awesome if we could allow use to paste an image (copied from Photos, Safari, etc) and we'd receive the event in a listener. I would like to initiate an image upload when user pastes an image.
Is there any workaround for this?

Accessibility (in editable mode)

Lexical's editable mode uses a UITextView as its frontend. We currently rely on UITextView's built in accessibility.

This means that we're not surfacing things like e.g. list items to VoiceOver. (VoiceOver reads out the text, but with no way of knowing that it's a bulleted list item rather than a paragraph.)

We should think about how we can surface that information to VoiceOver.

Misalignment of DecoratorNode due to Insertion of ElementNode or Additional DecoratorNode

I've noticed an issue regarding the layout misplacement of DecoratorNode when an ElementNode or another DecoratorNode is inserted before it. Upon debugging, I found out that the misalignment is caused by the system not marking the problematic DecoratorNode as 'dirty', which results in the layout not being recalibrated or updated. As such, I propose a fix that would include the marking of the DecoratorNode as 'dirty' to trigger a layout update whenever a new node is inserted. This should correct the alignment issue and ensure the layout is correctly adjusted for the new node.

Simulator.Screen.Recording.-.iPhone.15.-.2023-12-18.at.14.53.28.mp4
Simulator.Screen.Recording.-.iPhone.15.-.2023-12-18.at.14.55.56.mp4

Fix block styling to support empty lines in editor

Background

TextKit 1, which Lexical uses, doesn't really support block level elements. Margins/padding are applied at a paragraph level, where paragraph is defined as any text separated by newlines.

In Lexical, we wanted to allow nodes to behave like block level elements like they do on the web. For example, our Code node has some top/bottom margin and some padding in order to draw its bordered box.

The way Lexical iOS accomplishes this is by taking the node that is calling for top/bottom margin/padding, and applying the top margin to the first paragraph within that node, and the bottom margin to the last paragraph within that node.

Issues

I wrote the block level styling system as part of the Feed Inlines project, which uses read-only Lexical. I'm now making sure it works well with editable Lexical too.

There are two main issues that need addressing:

  • The metrics (i.e. spacing above/below) of a line don't update until the user has typed a character on that line. E.g. when switching paragraph style, or when entering a newline within a paragraph style that has expanded top/bottom margins.
  • There is a separate issue regarding when a paragraph style applies to the last line of the document, and the last line is empty.

Metric updating

There are/were two problems here:

  • The code for applying the block level attributes looked only at dirty nodes (and their range cache locations). It did not check if the node was unattached. And since this code was running before the garbage collection happened. it was finding unattached nodes, with a stale range cache, and applying the old block level attributes to that node's stale range.
  • When calculating which paragraphs needed to have top/bottom margin, the calculation was excluding the last line of the document (if said line was empty), because that's not a paragraph with any content in it according to TextKit.

Last line drawing

As well as the last line being excluded when calculating which paragraphs need top/bottom margin (as mentioned above), we also have the issue of how to actually apply any form of styling to the last line at all. As mentioned, it does not correspond to any range in the backing attributed string, so where do we apply attributes?

The last line is handled as a special case by TextKit, and is known as the extraLineFragment. TextKit places and styles it (as far as I can see) according to the last character on the previous line's attributes.

Status

I have written code (as yet unpublished) to fix the first part of this. I'm working on the extraLineFragment support, and am having some trouble getting it to work reliably.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.