joevallender / aboutwebdev

MIT License
0 stars 0 forks source link

blog/tiptap2-vue3-extending-image-functionality/ #2

Open utterances-bot opened 3 years ago

utterances-bot commented 3 years ago

Adding functionality to the image extension in tiptap 2 and Vue 3 | aboutweb.dev

Webdev related writings

https://aboutweb.dev/blog/tiptap2-vue3-extending-image-functionality/

joevallender commented 3 years ago

I'm aware this may not be the ideal implementation, so I've started a thread on the tiptap repo and will edit this or write a new post if there is a better way

georgeboot commented 3 years ago

I am super curious to your direct s3 upload. I was planning to build one myself but then got to this post. Anything to share?

joevallender commented 3 years ago

I am super curious to your direct s3 upload. I was planning to build one myself but then got to this post. Anything to share?

Hi @georgeboot it's using pre-signed URLs. You will need something on the server side to connect privately to the S3 (or compatible) API and get a single use URL with which you can PUT upload the file directly to S3.

The specific code depends on your back and front end language and framework choices. But here is a simplified example for Laravel and Vue.

This assumes you've handled the routing, added correct S3 credentials etc.

class UploadUrlController
{
    public function __invoke()
    {
        $s3 = Storage::disk('s3');
        // Get the underlying S3 client
        $client = $s3->getDriver()->getAdapter()->getClient();
        $expiry = "+20 minutes";

        // Have some sort of naming strategy
        $extension = Request::input('extension');
        $fileName = 'img-' . time() . '-' . Auth::user()->id . '.' . $extension;

        // Generate the pre signed URL
        $command = $client->getCommand('PutObject', [
            'Bucket' => Config::get('filesystems.disks.s3.bucket'),
            'Key'    => $fileName,
            'ACL'    => 'public-read'
        ]);
        $request = $client->createPresignedRequest($command, $expiry);
        $uploadURL = (string) $request->getUri();

        return [
            'url' => $uploadURL,
            'fileName' => $fileName,
            'filePath' => explode('?', $uploadURL)[0]
        ];
    }
}

And on the front end

async fileChanged() {
    const files = document.getElementById('fileUpload').files;
    if (files) {

        const config = {
            headers: // any authorisation needed
        }

        this.uploadProgress = 0
        const file = files[0];
        const fileName = file.name;
        const extension = fileName.split('.').pop()

        const response = await axios.post('/api/upload-url', { extension: extension }, config).catch(
            error => {
                // handle the error
            }
        );

        const options = {
            // If you want the uploaded file to be public
            headers: {
                'x-amz-acl': 'public-read'
            },
            onUploadProgress: (progressEvent) => {
                // Handle some visual feedback
            }
        };

        await axios.put(response.data.url, file, options).catch(
            error => {
                // handle the error
            }
        );
        const src = response.data.filePath
        this.doSomethingWithTheImage({ src })
        document.getElementById('fileUpload').value = null

    }
}

The ACL used when you generate the URL with getCommand must also be passed in the header of the PUT.

If you want private files, that's most likely the default in your setup and you can omit both.

georgeboot commented 3 years ago

Awesome, thanks! I am using Livewire so they take care of the upload to s3 already.

What I was planning:

joevallender commented 3 years ago

Yes that sounds absolutely fine :)

It sounds like you're perfectly on top of things already. Did I miss a specific part of your question?

georgeboot commented 3 years ago

Thanks a lot! My approach worked in my head but practise is usually different haha. Thanks for sharing your example, it reassures my solution might just work and will help a lot for the general structure.

I will share my code once ready. Someone else might just want to use it.

joevallender commented 3 years ago

I will share my code once ready. Someone else might just want to use it.

Nice one mate 👍

georgeboot commented 3 years ago

I made a package out of it: https://github.com/georgeboot/laravel-tiptap

How I solved the image upload part:

joevallender commented 3 years ago

Thanks for coming back George, that's a great package. You should submit it here https://laravel-news.com/links

Why did you implement your own S3 upload in the end? To keep the requirements to only Alpine.js and not also Livewire?

georgeboot commented 3 years ago

Why did you implement your own S3 upload in the end? To keep the requirements to only Alpine.js and not also Livewire?

I didn't like livewire doing an ajax call before each image. The solution I used, allows for one pre-signed URL that can be reused multiple times. But yeah, it's a minor thing. It also didn't play too well with the client-side resizing.

iamdhrooov commented 3 years ago

This helped a lot. I recently added it to my react project and it works good. Do you have any way we can show the local image till the image is uploaded on s3. And then after uploading we show the actual image from s3 signed url link?

joevallender commented 3 years ago

This helped a lot. I recently added it to my react project and it works good. Do you have any way we can show the local image till the image is uploaded on s3. And then after uploading we show the actual image from s3 signed url link?

You'll need to dig into the underlying ProseMirror plugin system add a Decoration. Here is an example from ProseMirror docs.

You can also search for ready made ProseMirror plugins which can, with some tweaking, be used with TipTap.

iamdhrooov commented 3 years ago

@joevallender Thanks for the example. I will have a look at it.

Igirid commented 2 years ago

@joevallender You did a nice job of extending the Image extension. Thank you. Although, defaultOptions is deprecated. There's a warning on my browser console to use addOptions instead.

joevallender commented 2 years ago

@joevallender You did a nice job of extending the Image extension. Thank you. Although, defaultOptions is deprecated. There's a warning on my browser console to use addOptions instead.

Thanks @Igirid :)

I published this as a quick experiment prior to any real documentation being published for TipTap v2... so I think a quite few parts could be either wrong, out of date, or at the very least be done better!

damienpjones commented 1 year ago

@joevallender This is really nice! I am wondering how you were getting urls for the image on each new load if they are in private s3 buckets as I couldn't see any url fetching in the extension code?

joevallender commented 1 year ago

Hi @damienpjones

See this comment https://github.com/joevallender/aboutwebdev/issues/2#issuecomment-895092263 for an example of how to save into a public s3 bucket (using PHP/Laravel for the server side, but it's similar in all languages) and return the image URL.

The example only uses the the response.data.filePath to display the image. But you can also save that URL anywhere you like. If it was, for example, a user profile you might have a user object and you can add user.profileImage and save that back to the server as you would user.name or any other text value

james-william-r commented 1 year ago

@joevallender This is great! Thanks so much for sharing!

I've noticed that when loading content direction into TipTap the attribute gets stripped, I'm new to this but I'm guessing I need to create some sorta addNodeView() maybe?

joevallender commented 1 year ago

@joevallender This is great! Thanks so much for sharing!

I've noticed that when loading content direction into TipTap the attribute gets stripped, I'm new to this but I'm guessing I need to create some sorta addNodeView() maybe?

I haven't worked with this exactly, but I guess you'd either want to add the dir attribute to your image node or, as this plugin appears to do, globally

james-william-r commented 1 year ago

@joevallender Thanks! Incase anyone else comes across this, I did some digging and it looking like global attributes is the best way to go, and recommended in the docs. 🤙

damienpjones commented 1 year ago

@joevallender I'm actually using private buckets that need signed urls so used a node view

    return ({ node }) => {
      const imgEl = document.createElement('img');

      imgEl.classList.add('rich-text-image');

      if (node.attrs.datasrc.includes('s3')) {
        getS3SignedUrl(node.attrs.datasrc).then((url) => {
          imgEl.src = url;
        });
      } else {
        imgEl.src = node.attrs.datasrc;
      }
      return {
        dom: imgEl,
      };
    };
  },

along with using a datasrc attribute which I use to set the HTML src attribute on each load

renderHTML({ node, HTMLAttributes }) {
    if (node.attrs.datasrc.includes('s3')) {
      getS3SignedUrl(node.attrs.datasrc).then((url) => {
        HTMLAttributes.src = url;
      });
    } else {
      HTMLAttributes.src = node.attrs.datasrc;
    }
    return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
  },
james-william-r commented 1 year ago

@damienpjones I ended up going down this route too, much simpler! ✨

sharperDev2112 commented 1 year ago

Thanks for this. Nicely done.

One issue I'm having is initially setting editor content with the images size attributes. They aren't showing in the DOM which appears to default to small always. I cloned your code and added class="custom-image custom-image-large" to the content when initialized (in line 207 of Editor.vue). Any idea what I am doing wrong?

sharperDev2112 commented 1 year ago

Got it...just added a parseHTML to look for classes and set the size attribute accordingly.