Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Can't use custom Tiptap extensions with Editor component #746

Open
philippbuerger opened this issue Nov 26, 2024 · 13 comments
Open

Can't use custom Tiptap extensions with Editor component #746

philippbuerger opened this issue Nov 26, 2024 · 13 comments
Assignees

Comments

@philippbuerger
Copy link

How can I access the current editor object in a custom toolbar button? I'm trying to implement the unset all marks function from tip tap.

@jeffchown
Copy link

@philippbuerger With a little experimentation, I was able to access the editor object and create a Clear content button to execute the TipTap clearContent() command (https://tiptap.dev/docs/editor/api/commands/content/clear-content) using the following code:

<div>
    <form>
        <flux:editor x-ref="myeditor" wire:model="content" />

        <div class="mt-4">
            <flux:button type="button" x-on:click="$refs.myeditor.editor.commands.clearContent()">
                Clear editor
            </flux:button>
        </div>
    </form>
</div>

Maybe this can help as a starting point for your exploration into custom toolbar buttons.

@philippbuerger
Copy link
Author

@jeffchown thanks for the hint. I was able implement the same logic with $el.closest('[data-flux-editor]').editor.commands.clearContent(). So there's no need for an extra ref. But your way also works perfect.

@philippbuerger
Copy link
Author

Thinking about what is the best way to install custom extensions for tiptap for e.g. https://tiptap.dev/docs/editor/extensions/nodes/table and using it? Any suggestions?

@jeffchown
Copy link

Not yet. I have some experimentation to do with the new Fluxified version of TipTap.
I have installed custom TipTap extensions in the past, but not yet in the Flux version.

I won't have time to experiment with this until next week, but will post any findings here when I do.

@mauritskorse
Copy link

Yes, I mentioned this in #739 (comment) as well.
But it would require to be able to extend on the editor js bundled with flux, wich doesnt seem to be possible yet.

@joshhanley
Copy link
Member

joshhanley commented Nov 28, 2024

@philippbuerger can you provide code for how you are currently trying to add a custom button or how you expect it to work, so we can replicate it and see what is the best way? Thanks!

@calebporzio
Copy link
Contributor

@philippbuerger - Your $el.closest('[data-flux-editor]').editor.commands.clearContent() is great as a solution for the original issue.

However, you're right, having a way to add your own extensions would be nice.

We could add an event hook or something that allows you to append your own extension object onto ours.

The tricky thing is that I think you have to include extensions when you initialize the editor (and can't after initialization).

This means we would need some kind of event-driven setup that allows you to add it at the right time in the boot process.

Something like this maybe?:

import TextAlign from '@tiptap/extension-text-align'

document.addEventListener('flux:editor', e => {
    e.target.appendExtension(TextAlign.configure({
        types: ['paragraph', 'heading'],
        alignments: ['left', 'center', 'right'],
    }),)
})

This would be part of your app bundle and you would have to make sure it executes before Flux's JS... (which may actually be difficult)


Can anyone else think of any other way out-of-the-box alternatives for this story?

I could see

  • Allowing people to bundle their own Flux build (there be dragons here)
  • Providing some kind of more limited PHP-side affordance or something. I don't know...

@jeffchown
Copy link

@calebporzio On first think, the event-driven/hook approach will probably be the best.

Anyone who wants to customize flux:editor to this degree will probably be comfortable (enough) to use the https://livewire.laravel.com/docs/installation#manually-bundling-livewire-and-alpine approach to loading Livewire/Alpine - which I assume will be necessary to install any bundle before Flux's JS.

The first of last two possibles you present:

  • Allowing people to bundle their own Flux build (there be dragons here)
    I think could turn into a mess of non-standard app configurations that could lead to challenging and very time-consuming support issues while also potentially causing unexpected side-effects effecting other aspects/components within the LAF ecosystem (bad acronym for Livewire, Alpine, Flux, I know, but better than 'FAL' or 'FLA' - and made me laugh on this Friday 😂)

@mauritskorse

This comment has been minimized.

@megawubs
Copy link

megawubs commented Dec 2, 2024

How about providing a global array of editor extension and once the flux editor initializes, it looks for this global array and appends it to its extensions? This way it can be part of your app bundle and you won't have to figure out when to load it.

Example:

import TextAlign from '@tiptap/extension-text-align'

window.fluxEditorExtensions = [
  TextAlign.configure({
          types: ['paragraph', 'heading'],
          alignments: ['left', 'center', 'right'],
      }
]

@joshhanley joshhanley changed the title Inject editor object in custom toolbar button CamInject editor object in custom toolbar button Dec 19, 2024
@joshhanley joshhanley changed the title CamInject editor object in custom toolbar button Can't use custom Tiptap extensions with Editor component Dec 19, 2024
@calebporzio calebporzio self-assigned this Dec 19, 2024
@gdebrauwer
Copy link

gdebrauwer commented Jan 7, 2025

This would be part of your app bundle and you would have to make sure it executes before Flux's JS... (which may actually be difficult)

@calebporzio I think we can use the event-driven setup and ensure it executes before Flux's Js by allowing users to provide a script url to the flux editor using a 'customize' attribute (or 'configure or 'script' attribute? don't know what the best name would be)

<flux:editor :customize="Vite::asset('resources/js/flux-editor-customizations.js') />

In the flux/editor/index.blade.php file, the provided file could be added to the @assets ... @endassets section:

<!-- In flux/editor/index.blade.php -->

@assets
	@if ($attributes->has('customize'))
        <script src="{{ $attributes->get('customize') }}"></script>
    @endif

    <flux:editor.scripts />
    <flux:editor.styles />
@endassets

If the custom script is not deferred, then that script will always be loaded before it even starts to download and load the flux editor script file if I'm not mistaken 🤔

Maybe it is even possible to make the customize script deferred by waiting for it to be loaded in Flux editor js file 🤔

// In Flux Editor JS

var customizeScript = document.querySelector('#customize-flux-editor-script');

if (customizeScript) {
    customizeScript.addEventListener('load', function() {
        // start executing the flux editor js code ...
    });
} else {
    // start executing the flux editor js code ...
}

The custom script can then use the event-driven setup you previously suggested:

// In resources/js/flux-editor-customizations.js of your laravel project

import TextAlign from '@tiptap/extension-text-align'

document.addEventListener('flux:editor', e => {
    e.target.appendExtension(TextAlign.configure({
        types: ['paragraph', 'heading'],
        alignments: ['left', 'center', 'right'],
    }),)
})

@gdebrauwer
Copy link

It should also be possible to customize the existing extensions.

In a project, I added custom protocols to the link extension using the following code:

window.customElements.whenDefined('ui-editor').then(() => {
    document.querySelectorAll('ui-editor').forEach(uiEditor => {
        let editor = uiEditor.editor;
        let toolbar = uiEditor.querySelector('ui-toolbar');

        editor.extensionManager.extensions.filter(extension => extension.name == 'link')[0].options.protocols = ['mailto', 'page', 'procedure', 'legislation'];
        editor.setOptions();
    });
})

This works if the editor does not have any content yet. If the editor is loaded with existing content and the content contains links with those custom protocols, then the links with those custom protocols are purged by TipTap. This is because those custom protocols are added to the extension's configuration after loading the initial editor.

To fix that, the Flux editor should have the ability to edit its default extensions while also adding new extensions.

@mauritskorse
Copy link

mauritskorse commented Feb 10, 2025

I have a little different working approach. It uses the native connectedCallback functionality of webcomponents.
Using this function I dynamically load a separate config file with the extensions (also custom) at runtime. The config array is then passed to a method that creates the TipTap editor instance on the element. That way you can use an attribute with a reference to the config file you want to use. But you can also add other attributes which are passed onto your config file. There you can reference them when configuring your extensions. I.e. pass a placeholder to the placeholder extension, the history depth to the history extension, etc. Since the this config file is loaded before the TipTap editor is initiated it also works in case content is already present in the editor.

The default config file is set in the components blade file and can be changed by passing a different config file in the src attribute. Note that this config file should be build as a module (to use named exports).

Below is my implementation.
note: there might be some artifacts of other changes I made because I added json support, livewire file upload, and I am working on / command support

editor/index.blade.php

@props([
    'name' => $attributes->whereStartsWith('wire:model')->first(),
    'toolbar' => null,
    'invalid' => null,
+   'src' => 'resources/js/vendor/flux/editor/default.module.min.js',
    'format' => 'json',
])

          data-flux-editor 
+         src="{{ Vite::asset( $src ) }}"
          format="{{ $format }}" {{-- html or json --}}
          > 
          <?php if ($slot->isEmpty()): ?>
              <flux:editor.toolbar :items="$toolbar" />

in editor.js there are quite some changes to make this work

    boot() {
        // Extract initial content. If the content is coming from Livewire, we need to use
        // the value attribute, otherwise load it from the editor content element...
-       let content = this.value ?? this.querySelector('ui-editor-content').innerHTML
+       this.content = this.getAttribute('content') || this.querySelector('ui-editor-content').innerHTML || '';
        this.format = this.getAttribute('format') || 'html';

        toolbar = this.querySelector('ui-toolbar')
        commands = this.querySelector('ui-commands')

        this.querySelector('ui-editor-content').innerHTML = ''

        this._controllable = new Controllable(this)
        this._disableable = new Disableable(this)
        this._droppable = new Droppable(this)
        this.$wire = this.closest('[wire\\:id]')?.__livewire.$wire;
+    }
+
+    initializeEditor(config) {
-        this.editor = new Editor({
-            element: this.querySelector('ui-editor-content'),
-            content: content,
+        // Initialize the TipTap editor with the provided configuration
+        const defaultConfig = {
            injectCSS: false,
            editable: true,
            editorProps: { // Prevent the default ProseMirror input event so we can dispatch our own...
                handleDOMEvents: { input: (view, event) => { event.stopPropagation(); return true; } }
            },
-            extensions: [
-                StarterKit.configure({
-                    history: true,
-                    paragraph: {
-                        keepOnSplit: false,
-                        allowNesting: true,
-                    },
-                }),
-                TextAlign.configure({
-                    types: ['paragraph', 'heading'],
-                    alignments: ['left', 'center', 'right'],
-                }),
-                Underline,
-                Link.configure({
-                    openOnClick: false,
-                    linkOnPaste: true,
-                }),
-                Highlight,
-                Subscript,
-                Superscript,
-                Placeholder.configure({
-                    placeholder: this.getAttribute('placeholder'),
-                }),
-            ],

            onBlur: () => {
                this.dispatchEvent(new Event('blur', {
                    bubbles: false,
                    cancelable: true,
                }))
            },

            onUpdate: () => {
                this._controllable.dispatch()
            },

            onCreate: ({ editor }) => {
                editor.view.dom.setAttribute('data-slot', 'content')

+                // Dispatch a custom event to signal nested components such as the toolbar and commands that the editor is ready.
+                this.dispatchEvent(new CustomEvent('editor-ready', {
+                    detail: { editor: this.editor },
+                    bubbles: true, // so that children can catch it via this.closest()
+                }));

                // Tiptap handles most keyboard shortcuts, however we need to implement link shortcuts...
                this.addEventListener('keydown', e => {
                    let cmdIsHeld = e.metaKey || e.ctrlKey

                    if (cmdIsHeld && e.key.toLowerCase() === 'k') {
                        e.preventDefault(); e.stopPropagation();

                        this.querySelector('[data-editor="link"] [data-match-target]')?.click()
                    }
                })

                toolbar && initializeToolbar(editor, toolbar)
            }
        }

+        // merge default config with the provided config, so that the provided config can recursively override the default one
+        const mergedConfig = { ...defaultConfig, ...config }
+
+        this.editor = new Editor({
+            element: this.querySelector('ui-editor-content'),
+            content: this.content,
+            ...mergedConfig,
+        })

        // This ensures that Tiptap doesn't emit an update event when the editor component is being booted. This
        // fixes an issue with `wire:model.live` is dispatching an update request on editor load, and fixes the
        // editor content being set to empty when Livewire and Alpine are manually bundled in `app.js`...
        let shouldEmitUpdate = false

add connectedCallback function in editor.js

    // is a lifecycle method that is called when the element is added to the DOM.
    async connectedCallback() {
        // Gather all attributes into an object
        const options = {};
        for (const attr of this.attributes) {
            options[attr.name] = attr.value;
        }

        const configSrc = this.getAttribute('src');
        
        if (configSrc) {
            try {
                const module = await import(/* @vite-ignore */configSrc);

                // Get the config factory function from the module.
                const createConfig = module.default;

                // Pass all attributes and get the config
                const config = createConfig(options);

                this.initializeEditor(config);
            } catch (err) {
                console.error('Error loading TipTap config:', err);
            }
        } else {
            console.warn('No configuration source provided for tiptap-editor.');
        }
    }

replace UIEditorContent in editor.js with this:

class UIEditorContent extends UIElement {
    boot() {
        const parent = this.closest('ui-richeditor');
        if (!parent) {
            throw `ui-editor-content: no parent ui-richeditor element found`;
        }

        // Check if the editor instance is already set.
        if (parent.editor) {
            this.editor = parent.editor;
        } else {
            // Listen for the custom event when the editor becomes available.
            parent.addEventListener('editor-ready', event => {
            this.editor = event.detail.editor;
            // You can also perform any further initialization here.
            });
        }
    }
}

Then the default.module.js or your custom config.module.js

import Placeholder from '@tiptap/extension-placeholder'
import Superscript from '@tiptap/extension-superscript'
import TextAlign from '@tiptap/extension-text-align'
import Highlight from '@tiptap/extension-highlight'
import Underline from '@tiptap/extension-underline'
import Subscript from '@tiptap/extension-subscript'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'

// options are all attributes set on the ui-editor tag.
export default function createConfig(options = {}) {
    return {

        // The array of extensions (TipTap expects functions/classes for extensions)
        extensions: [
            StarterKit.configure({
                history: true,
                paragraph: {
                    keepOnSplit: false,
                    allowNesting: true,
                },

                // issue "Error loading TipTap config: RangeError: Adding different instances of a keyed plugin (plugin$)
                // therefore disabled Dropcursor and Gapcursor
                dropcursor: false,
                gapcursor: false,
            }),
            TextAlign.configure({
                types: ['paragraph', 'heading'],
                alignments: ['left', 'center', 'right'],
            }),
            Underline,
            Link.configure({
                openOnClick: false,
                linkOnPaste: true,
            }),
            Highlight,
            Subscript,
            Superscript,
            Placeholder.configure({
                placeholder: options.placeholder || 'Type here...',
            }),
            
        ]
    };
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

7 participants