facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
19.06k stars 1.61k forks source link

Feature: Example of Vite config to fully bundle lexical+plugins+react into a JS and a CSS file #6175

Open vadimkantorov opened 3 months ago

vadimkantorov commented 3 months ago

I'm developing a single-file HTML app (for ease of distribution and drop-in) which needs a rich-text editor. I'm currently using Lexical for this purpose.

Extracting one of asks from https://github.com/facebook/lexical/issues/5840, for non-webdevs like me, it would be greate to have an example of Vite config bundling Lexical+Plugins+React into:

1) one JS and one CSS file - e.g. for deployment to GitHub pages (the existing vite config produces many CSS/font/image/JS files) 2) the JS/CSS should be fully embeddable in <script>...</script> and <style>...</style> and it should be possible to somehow register a Promise to be triggered when initialization of the editor completes (and ideally also a synchronous initialization should be supported)

Am I correct that (1) should already be achievable in https://vitejs.dev/guide/build#library-mode ?

etrepum commented 3 months ago

This is more of a vite question than anything else. Initialization of the editor is synchronous, no promises are involved unless one of the plug-ins is doing something async (but there isn't a general way to determine that).

vadimkantorov commented 3 months ago

I'll try to come up with such a config (or patch to the default config), but as long as it can fulfill (1), it can become also a Lexical distribution option and feature (e.g. bundled core and all Lexical plugins available in the lexical repo), similar to Quill

vadimkantorov commented 3 months ago

So I modified the vite.prod.config.js to be like (full example in https://github.com/vadimkantorov/lexical-playground-only):

 rollupOptions: {
      input: {
        main: new URL('./index.html', import.meta.url).pathname,

      },

      output: {
            format: 'es',
            manualChunks: false,
            inlineDynamicImports: true,
            entryFileNames: '[name].js',   // currently does not work for the legacy bundle
            assetFileNames: '[name].[ext]', // currently does not work for images
      },

    },

This produces main.js, main.css, landscape.jpg, yellow-flower.jpg, cat-typing.gif and a bunch of svg/woff/woff2/ttf files.

To embed the svg/woff/woff2/ttf into the CSS I wrote a following embedder:

# dataurifycss.py
# python dataurifycss.py assets/main.css

import os, sys, re, base64
mime = {'.svg': 'image/svg+xml', '.jpg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf'}
with open(sys.argv[1], 'w') as f: css = f.read()
root_dir = os.path.dirname(sys.argv[1])
seen = set()
def dataurify(s):
    o = s.group(0)
    g = s.group(1).strip("'" + '"')
    e = os.path.splitext(g)[-1]
    p = os.path.join(root_dir, g.lstrip('/')) if g.startswith('/') else g
    if g.startswith('data:'):
        return g
    elif e in mime:
        t = 'url(data:{mime};base64,{encoded})'.format(mime = mime[e], encoded = base64.b64encode(open(p, 'rb').read()).decode())
        seen.add(p)
        return t
    else:
        return o
pattern = 'url\((.+?)\)'
res = re.sub(pattern, dataurify, css)
for p in seen: os.remove(p)
with open(sys.argv[1], 'w') as f: f.write(res)
# dataurifyjs.py
# python dataurifyjs.py assets/main.js

import os, sys, re, base64
mime = {'.jpg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif'}
with open(sys.argv[1], 'r') as f: css = f.read()
root_dir = os.path.dirname(sys.argv[1])
seen = set()
def dataurify(s):
    o = s.group(0)
    g = s.group(1).strip("'" + '"')
    e = os.path.splitext(g)[-1]
    p = os.path.join(root_dir, g.lstrip('/')) if g.startswith('/') else g
    if g.startswith('data:'):
        return g
    elif e in mime:
        t = '"data:{mime};base64,{encoded}"'.format(mime = mime[e], encoded = base64.b64encode(open(p, 'rb').read()).decode())
        seen.add(p)
        return t
    else:
        return o
pattern = '"(/.+?\.(' + '|'.join(ext.lstrip('.') for ext in mime) + '))"'
res = re.sub(pattern, dataurify, css)
for p in seen: os.remove(p)
with open(sys.argv[1], 'w') as f: f.write(res)

The JPG and GIF files can be embedded in a similar way with dataurifyjs.py, which gives self-contained main.js and main.css files

So I would propose: 1) to somehow fix the usage of JPG/GIF files to be also in CSS (currently they are used in main.js as var landscapeImage = "/landscape.jpg";/var yellowFlowerImage = "/yellow-flower.jpg";/var catTypingGif = "/cat-typing.gif";) and have an option/explainer to remove these examples files. They are quite heavy and increase the size of the JS if embedded. 2) and maybe include in README or in repo/examples a kind of vite.prod.config.js to have such a target that produces only a main.js/main.css with all images/fonts embedded. If vite doesn't have such image/font embedder, maybe this can also be done with a JavaScript snippet similar to my Python snippets - and then this snippet can be added in vite.prod.config.js

etrepum commented 3 months ago

Vite does inlining when it makes sense (<4kb), at least in situations when you are using it for resolution of the asset (imported from js or using relative paths in css). You can configure it to behave differently https://vitejs.dev/config/build-options.html#build-assetsinlinelimit

I don't think it's a terribly common use case to want to inline everything into a pair of CSS and JS files. It's not very clear what the use case would be where you could use two files, but not a directory with more than two files.

If everything is all bundled up, how would you propose that the editor remains extensible? Lexical is an extensible framework for building text editors, not a specific text editor. It's unclear what you would do with an "drop-in" playground editor that is built for demonstration purposes and doesn't really have infrastructure for serialization/deserialization or configuration.

vadimkantorov commented 3 months ago

I think, a single or a pair of CSS/JS with some preset amount of plugins make a great starting point for non-webdevs (like machine learning devs) who just need a markdown/rtf editor as part of a small hack. In this usecase, Lexical + plugins should just provide their APIs, but adding of a new plugin at runtime is a non-goal

I think it's fair to say that when extensibility becomes needed, that user can invest some time in learning react/npm/vite etc. So not allowing extensibility in the two-files-distribution is okay and allows to try out and already use this modern editor with good markdown support out of the box.

etrepum commented 3 months ago

What would be the goals of getting this into the Lexical distribution, rather than having a standalone project with a very clear purpose? I think that even if Lexical provided this example, it would not be very easy to find or use, because the vast majority of Lexical's resources are dedicated to the more general use case.

Perhaps a purpose-built editor that depends on Lexical, but not part of the Lexical distribution, currently makes more sense for your purposes? For example, https://mdxeditor.dev/ is based on Lexical and its usage and documentation are all very focused on the markdown editor use case, and because it takes a stance on framework/asset/CSS management it can include UI components much more easily than the Lexical distribution.

vadimkantorov commented 3 months ago

Basically my ask was providing some automated releases with limited functionality for non-web developers (like myself) who do not need extensibility, but rather would appreciate an option with very simple <script src=""> import for having a fully-functional basic RTF editor supporting Markdown, non requiring to learn/install react to have something minimally working in the browser. If such a hack evolves and such a user needs more flexibility/UI complexity, surely they would migrate to a full React-based offering.

vadimkantorov commented 2 months ago

Upgrading to release v0.16.0 broke my editor: (not minified) main.js increased in size from 12Mb to 26Mb (for producing a single main.js output I'm doing in rollupOptions: output: { format: 'iife',/*'es',*/ compact: false, manualChunks: false, inlineDynamicImports: true, entryFileNames: '[name].js', /* currently does not work for the legacy bundle*/ assetFileNames: '[name].[ext]',} in packages/lexical-playground/vite.prod.config.ts

Also, ActionsPlugin now at loading produces the error (at release 0.12.5 there was no such error):

main.js:9173 Error: Missing FlashMessageContext
    at useFlashMessageContext (main.js:33801:13)
    at useFlashMessage (main.js:33806:12)
    at ActionsPlugin (main.js:63984:30)
    at Nh (main.js:8630:10)
    at Vk (main.js:11804:14)
    at Uk (main.js:11471:14)
    at Tk (main.js:11464:7)
    at Ik (main.js:11447:9)
    at Nk (main.js:11163:10)
    at Gk (main.js:11094:60)

(I'm doing minify: false but I still could not figure out how to disable obfuscation/minification completely)

 const useFlashMessageContext = ()=>{
        const ctx = reactExports.useContext(Context$1);
        if (!ctx) {
            throw new Error("Missing FlashMessageContext");
        }
        return ctx;
    }
    ;

Would you have a suggestion on how to fix this FlashMessageContext thing? Thanks!

etrepum commented 2 months ago

Hard to say without being able to see the project but doubling of size makes it seem like you're including multiple versions of dependencies which will cause all sorts of problems. ActionsPlugin isn't something that's exported from any public lexical package so whatever broke there is in your project.

vadimkantorov commented 2 months ago

@etrepum My project is a simplified version of App.tsx from playground. I just fixed this problem by adding import {FlashMessageContext} from './context/FlashMessageContext'; and inserting a FlashMessageContext inside a LexicalComposer. I think in recent version ActionPlugin refuses to load if it cannot find an existing FlashMessageContext

vadimkantorov commented 2 months ago

Coming back to getting a single main.js file, currently when using output: { format: 'iife',/*'es',*/ compact: false, manualChunks: false, inlineDynamicImports: true, entryFileNames: '[name].js', /* currently does not work for the legacy bundle*/ assetFileNames: '[name].[ext]', } produces the following build/assets directory listing:

KaTeX_AMS-Regular.ttf
KaTeX_AMS-Regular.woff
KaTeX_AMS-Regular.woff2
KaTeX_Caligraphic-Bold.ttf
KaTeX_Caligraphic-Bold.woff
KaTeX_Caligraphic-Bold.woff2
KaTeX_Caligraphic-Regular.ttf
KaTeX_Caligraphic-Regular.woff
KaTeX_Caligraphic-Regular.woff2
KaTeX_Fraktur-Bold.ttf
KaTeX_Fraktur-Bold.woff
KaTeX_Fraktur-Bold.woff2
KaTeX_Fraktur-Regular.ttf
KaTeX_Fraktur-Regular.woff
KaTeX_Fraktur-Regular.woff2
KaTeX_Main-Bold.ttf
KaTeX_Main-Bold.woff
KaTeX_Main-Bold.woff2
KaTeX_Main-BoldItalic.ttf
KaTeX_Main-BoldItalic.woff
KaTeX_Main-BoldItalic.woff2
KaTeX_Main-Italic.ttf
KaTeX_Main-Italic.woff
KaTeX_Main-Italic.woff2
KaTeX_Main-Regular.ttf
KaTeX_Main-Regular.woff
KaTeX_Main-Regular.woff2
KaTeX_Math-BoldItalic.ttf
KaTeX_Math-BoldItalic.woff
KaTeX_Math-BoldItalic.woff2
KaTeX_Math-Italic.ttf
KaTeX_Math-Italic.woff
KaTeX_Math-Italic.woff2
KaTeX_SansSerif-Bold.ttf
KaTeX_SansSerif-Bold.woff
KaTeX_SansSerif-Bold.woff2
KaTeX_SansSerif-Italic.ttf
KaTeX_SansSerif-Italic.woff
KaTeX_SansSerif-Italic.woff2
KaTeX_SansSerif-Regular.ttf
KaTeX_SansSerif-Regular.woff
KaTeX_SansSerif-Regular.woff2
KaTeX_Script-Regular.ttf
KaTeX_Script-Regular.woff
KaTeX_Script-Regular.woff2
KaTeX_Size1-Regular.ttf
KaTeX_Size1-Regular.woff
KaTeX_Size1-Regular.woff2
KaTeX_Size2-Regular.ttf
KaTeX_Size2-Regular.woff
KaTeX_Size2-Regular.woff2
KaTeX_Size3-Regular.ttf
KaTeX_Size3-Regular.woff
KaTeX_Size4-Regular.ttf
KaTeX_Size4-Regular.woff
KaTeX_Size4-Regular.woff2
KaTeX_Typewriter-Regular.ttf
KaTeX_Typewriter-Regular.woff
KaTeX_Typewriter-Regular.woff2
apple-touch-icon.png
cat-typing.gif
esm/index.html
esm/index.mjs
esm/prepopulatedRichText.mjs
esm/styles.css
favicon-16x16.png
favicon-32x32.png
favicon.ico
index.html
landscape.jpg
main.js
yellow-flower.jpg

All svg menu images in 0.16.0 are now embedded in main.js by default (was not the case in 0.12.5)

Could you recommend any options for rollup/vite to make embed these KaTeX fonts into main.js?

Also, somehow unminified main.js size (practically it's just lexical-playground) rose from 8Mb to 24Mb when updated from release 0.12.5 to 0.16.0.

Or could I now use esm/index.mjs/esm/styles.css for this purpose?

etrepum commented 2 months ago

Looking at the playground it seems that most of the build size comes from excalidraw

$ find packages/lexical-playground/build -name "*.js"|xargs du -hc
4.0K    packages/lexical-playground/build/assets/ImageResizer-BOglis69.js
4.0K    packages/lexical-playground/build/assets/EquationComponent-0rKEFys_.js
152K    packages/lexical-playground/build/assets/parser-html-C9wyCVVk.js
1.3M    packages/lexical-playground/build/assets/main-Cx1fdMhU.js
4.0K    packages/lexical-playground/build/assets/PollComponent-CB4jlrjq.js
4.0K    packages/lexical-playground/build/assets/LexicalNestedComposer-fvfXQv0w.js
152K    packages/lexical-playground/build/assets/parser-postcss-DWk0NhTE.js
4.0K    packages/lexical-playground/build/assets/StickyComponent-Cz0oLPVj.js
4.0K    packages/lexical-playground/build/assets/ImageComponent-RRTY-P5X.js
432K    packages/lexical-playground/build/assets/standalone-CukwHxG0.js
160K    packages/lexical-playground/build/assets/parser-markdown-C4fzb1xR.js
 20M    packages/lexical-playground/build/assets/ExcalidrawComponent-X4FaPXXk.js
308K    packages/lexical-playground/build/assets/parser-babel-U9l6AbUH.js
4.0K    packages/lexical-playground/build/assets/InlineImageComponent-B4WSWRO-.js
 22M    total

Beyond that you're really just using code in an unsupported way. The playground is all demo code built with lexical's build infrastructure, it's not designed to be easily extractable into a standalone project.

The esm folder is not relevant to what you're trying to do, it's basically just a demo that lexical can be used without a bundler at all. https://playground.lexical.dev/esm/

vadimkantorov commented 2 months ago

I made a cleaner extraction of lexical-playground: https://github.com/vadimkantorov/moncms

It contains packages/lexical-playground and packages/shared, without any modifications, from lexical 0.16.0. The only needed patch is to packages/lexical-playground/vite.prod.config.ts and specified in https://github.com/vadimkantorov/moncms/blob/gh-pages/Makefile#L46-L56

You can find the produced main.js ( 26 megabytes :( ) in https://github.com/vadimkantorov/moncms/raw/gh-pages/assets/main.js . The build dir produced by Makefile in fact contains only main.js and no other js files that you listed above (probably I don't have them because of rollup's output options I used in Makefile)

The root tsx file is https://github.com/vadimkantorov/moncms/blob/gh-pages/indexEditorOnly.tsx (modified merger of packages/lexical-playground/src/index.tsx and packages/lexical-playground/src/Editor.tsx)

So far problems with this approach:

Attaching the build artifacts (produced by https://github.com/vadimkantorov/moncms/blob/gh-pages/.github/workflows/build.yml): lexicalplaygroundonlyassets (15).zip