Using Vue's custom directives to intercept and modify pasted content

Using Vue's custom directives to intercept and modify pasted content

22 Aug 2024

Recently I found a neat use-case for Vue custom directives in combination with input fields that are output by a component library.

In my case, that library is the (mostly) excellent Naive UI, and outputting a basic input field looks like this:

<n-input v-model:value='model.foo' />

...assuming model has been set up like so:

import { ref } from 'vue' const model = ref(null);

We had a situation whereby colleagues were pasting content into the field from Google Docs, which meant two issues:

  • Leading and/or trailing space
  • Curly quotes

Now, with a native input field, i.e. without a component library, the first issue is easily solved; Vue provides a built-in modifier, .trim, to trim content. So the following field would never commit any leading or trailing space to its model.

<input v-model.trim='model.foo' />

That's not possible in the first example, though, because the native field is abstracted away in the Naive UI component - we don't have access to it. It would be up to Naive UI to support the .trim modifier - and it doesn't.

We also can't use a template ref for the same reason, namely that we don't have access to the element. And if we use a ref attribute on a component rather than a native element, we don't get access to its root node, we instead get access to whatever the component exports.

So what are our options? One thing we could do is set up a watcher, to listen for changes to the model and act accordingly.

import { ref, watch } from 'vue' const model= ref(null); watch(model, value => mode.value = value .replace(/“|”/g, '"') .replace(/‘|’/g, "'") );

That works fine, but the issue is it runs constantly, every time our model is updated (so every key stroke, every paste, deletion, etc.) That can get expensive with lots of feilds - and anyway, my use-case was not to guard against manually typing curly quotes (this is allowed), but preventing them from being pasted.

And there's no way we can listen for paste events on a field we don't have access to (short of some DOM black magic that circumvents Vue.)

Or is there? Custom directives can help us. Now, I should warn you, this is technically bad practice; Vue warns us against using custom directives on components, on the grounds that they don't work if the component has multiple root nodes. That is, custom directives depend on a single node, not multiple.

Well, fair enough. That doesn't seem like an "avoid at all costs" to me, so long as you know the component in question has a single root node only. And that's true of n-input.

With that in mind, I created a file, custom-directives.js, which exports - you guessed it - my custom directives. This is not a tutorial on custom directives per-se (if you're hazy on them, here's the docs; there's not much too them), just an article to show how they can help with otherwise inaccessible elements.

In custom-directives.js, I put this:

export const vNoPastedCurlyQuotes = { created(el, binding) { } }

Custom directives, when used in conjunction with the setup script, must begin with v. That's how Vue finds them and binds them to elements/components.

The first argument passed to a custom directive callback is the element (or component's root element) you're binding the directive to. Voila - we have access to the element inside the component!

Before we flesh out the directive, let's look at how we'll bind it to the component (and, ultimately, its root element, the field.)

<n-input v-model:value='model.foo' v-noPastedCurlyQuotes='{model, key: "foo"}' />

Notice that we're passing our model, and the key within it that the field is modelled to, as parameters. These turn up in the directive callback as the second argument, wrapped in a ref, (the first argument is the element).

The rest pretty much writes itself; it just involves our callback binding a paste event to the element and updating the model as necessary if curly quotes are found (replacing them with straight ones.)

export const vNoPastedCurlyQuotes = { created(el, params) { el.addEventListener('paste', async evt => { await new Promise(res => setTimeout(res, 1)); const content = params.value.model[params.value.key]; params.value.model[params.value.key] = content .replace(/“|”/g, '"') .replace(/‘|’/g, "'") }); } }

Remember above I said any passed params were wrapped in a ref; that's why we need params.value, not params.

What's that timeout doing? The paste event fires before the input event (which, behind the scenes, Vue's model binding uses, I believe), so we need to wait a moment (in this case, 1ms) for the model to be updated before we can do our thing.

And that's it!

Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!