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

Ability to insert variables, similar to suggestions #329

Closed
moltar opened this issue May 27, 2019 · 19 comments
Closed

Ability to insert variables, similar to suggestions #329

moltar opened this issue May 27, 2019 · 19 comments
Labels
Type: Feature The issue or pullrequest is a new feature

Comments

@moltar
Copy link

moltar commented May 27, 2019

Is your feature request related to a problem? Please describe.

Ability to insert pre-defined "variables" into the text.

E.g. user could type "today is {{ today }}" or "today is $today".

The editor would provide a list of suggested variables that are pre-defined.

The editor would also decorate these variables somehow once they are inserted.

The editor would allow to backspace and erase them.

Describe the solution you'd like

Similar to how suggestions work. But ability to use multiple characters, I guess. Like "{{" to begin suggestions.

Describe alternatives you've considered

N/A

Additional context

Integromat has a similar looking feature, but does not provide a suggest dropdown when typing. Can only use a mouse. http://take.ms/HSf3l

Zapier also has a similar one http://take.ms/NhODH


Thanks!

@moltar moltar added the Type: Feature The issue or pullrequest is a new feature label May 27, 2019
@moltar
Copy link
Author

moltar commented May 27, 2019

I think I have figured out a work-around:

https://codesandbox.io/embed/vue-template-y0o5z (see HelloWorld.vue)

Notably:

.mention::after {
  content: "}}";
}

and

          new Mention({
            matcher: {
              char: "{{"
            },

Seems to work!

If you have any better suggestions, please let me know.

If you think this is the best way, then please feel free to close this issue.

Thanks!

@asseti6
Copy link

asseti6 commented May 28, 2019

@moltar this is completely possible similiar to how you are using the Mention class already by defining your own custom node and plugin or use the suggestions plugin already available. As a working example your node would look something like:

import { Node } from "tiptap";
import { replaceText } from "tiptap-commands";
import VariableSuggestionPlugin from "../plugins/CustomSuggestions";

export default class Variables extends Node {
  get name() {
    return "variable";
  }

  get defaultOptions() {
    return {
      matcher: {
        char: "%%",
        allowSpaces: false,
        startOfLine: false
      },
      variableClass: "variable",
      suggestionClass: "variable-suggestion"
    };
  }

  get schema() {
    return {
      attrs: {
        id: {},
        label: {}
      },
      group: "inline",
      inline: true,
      selectable: false,
      atom: true,
      toDOM: node => [
        "span",
        {
          class: this.options.variableClass,
          "data-variable-id": node.attrs.id
        },
        `${this.options.matcher.char}${node.attrs.label}`
      ],
      parseDOM: [
        {
          tag: "span[data-variable-id]",
          getAttrs: dom => {
            const id = dom.getAttribute("data-variable-id");
            const label = dom.innerText
              .split(this.options.matcher.char)
              .join("");
            return { id, label };
          }
        }
      ]
    };
  }

  commands({ schema }) {
    return attrs => replaceText(null, schema.nodes[this.name], attrs);
  }

  get plugins() {
    return [
      VariableSuggestionPlugin({
        command: ({ range, attrs, schema }) =>
          replaceText(range, schema.nodes[this.name], attrs),
        appendText: " ",
        matcher: this.options.matcher,
        items: this.options.items,
        onEnter: this.options.onEnter,
        onChange: this.options.onChange,
        onExit: this.options.onExit,
        onKeyDown: this.options.onKeyDown,
        onFilter: this.options.onFilter,
        suggestionClass: this.options.suggestionClass
      })
    ];
  }
}

Further if you want true variable previews you can find the tags in the html output and replace them (adding a tooltip with the original variable name) if you want to get a touch more complex. We have added a click event to that tooltip to allow it to be modified again by setting the replacement value to something a bit more complex. As an example of basic "variable preview":

this.editor.setContent(this.editor.getHTML().replace(new RegExp(`<span class=("|"([^"]*))variable("|([^"]*)").*?([^<]+)%%${variable.name}</span>`, "g"), variable.value));

For the Custom Plugin to support the class you could use the Mentions Suggestion Plugin as a base and slightly modify. Something to the following effect:

import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { insertText } from "tiptap-commands";

// Create a matcher that matches when a specific character is typed. Useful for @mentions and #tags.
function triggerCharacter({
  char = "%%",
  allowSpaces = false,
  startOfLine = false
}) {
  return $position => {
    // Matching expressions used for later
    const escapedChar = `\\${char}`;
    const suffix = new RegExp(`\\s${escapedChar}$`);
    const prefix = startOfLine ? "^" : "";
    const regexp = allowSpaces
      ? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${escapedChar}|$)`, "gm")
      : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, "gm");

    // Lookup the boundaries of the current node
    const textFrom = $position.before();
    const textTo = $position.end();
    const text = $position.doc.textBetween(textFrom, textTo, "\0", "\0");

    let match = regexp.exec(text);
    let position;

    while (match !== null) {
      // JavaScript doesn't have lookbehinds; this hacks a check that first character is " "
      // or the line beginning
      const matchPrefix = match.input.slice(
        Math.max(0, match.index - 1),
        match.index
      );

      if (/^[\s\0]?$/.test(matchPrefix)) {
        // The absolute position of the match in the document
        const from = match.index + $position.start();
        let to = from + match[0].length;

        // Edge case handling; if spaces are allowed and we're directly in between
        // two triggers
        if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
          match[0] += " ";
          to += 1;
        }

        // If the $position is located within the matched substring, return that range
        if (from < $position.pos && to >= $position.pos) {
          position = {
            range: {
              from,
              to
            },
            query: match[0].slice(char.length),
            text: match[0]
          };
        }
      }

      match = regexp.exec(text);
    }

    return position;
  };
}

export default function VariableSuggestionPlugin({
  matcher = {
    char: "%%",
    allowSpaces: false,
    startOfLine: false
  },
  appendText = null,
  suggestionClass = "suggestion",
  command = () => false,
  items = [],
  onEnter = () => false,
  onChange = () => false,
  onExit = () => false,
  onKeyDown = () => false,
  onFilter = (searchItems, query) => {
    if (!query) {
      return searchItems;
    }

    return searchItems.filter(item =>
      JSON.stringify(item)
        .toLowerCase()
        .includes(query.toLowerCase())
    );
  }
}) {
  return new Plugin({
    key: new PluginKey("custom-suggestions"),

    view() {
      return {
        update: (view, prevState) => {
          const prev = this.key.getState(prevState);
          const next = this.key.getState(view.state);

          // See how the state changed
          const moved =
            prev.active && next.active && prev.range.from !== next.range.from;
          const started = !prev.active && next.active;
          const stopped = prev.active && !next.active;
          const changed = !started && !stopped && prev.query !== next.query;
          const handleStart = started || moved;
          const handleChange = changed && !moved;
          const handleExit = stopped || moved;

          // Cancel when suggestion isn't active
          if (!handleStart && !handleChange && !handleExit) {
            return;
          }

          const state = handleExit ? prev : next;
          const decorationNode = document.querySelector(
            `[data-decoration-id="${state.decorationId}"]`
          );

          // build a virtual node for popper.js or tippy.js
          // this can be used for building popups without a DOM node
          const virtualNode = decorationNode
            ? {
                getBoundingClientRect() {
                  return decorationNode.getBoundingClientRect();
                },
                clientWidth: decorationNode.clientWidth,
                clientHeight: decorationNode.clientHeight
              }
            : null;

          const props = {
            view,
            range: state.range,
            query: state.query,
            text: state.text,
            decorationNode,
            virtualNode,
            items: onFilter(
              Array.isArray(items) ? items : items(),
              state.query
            ),
            command: ({ range, attrs }) => {
              command({
                range,
                attrs,
                schema: view.state.schema
              })(view.state, view.dispatch, view);

              if (appendText) {
                insertText(appendText)(view.state, view.dispatch, view);
              }
            }
          };

          // Trigger the hooks when necessary
          if (handleExit) {
            onExit(props);
          }

          if (handleChange) {
            onChange(props);
          }

          if (handleStart) {
            onEnter(props);
          }
        }
      };
    },

    state: {
      // Initialize the plugin's internal state.
      init() {
        return {
          active: false,
          range: {},
          query: null,
          text: null
        };
      },

      // Apply changes to the plugin state from a view transaction.
      apply(tr, prev) {
        const { selection } = tr;
        const next = { ...prev };

        // We can only be suggesting if there is no selection
        if (selection.from === selection.to) {
          // Reset active state if we just left the previous suggestion range
          if (
            selection.from < prev.range.from ||
            selection.from > prev.range.to
          ) {
            next.active = false;
          }

          // Try to match against where our cursor currently is
          const $position = selection.$from;
          const match = triggerCharacter(matcher)($position);
          const decorationId = (Math.random() + 1).toString(36).substr(2, 5);

          // If we found a match, update the current state to show it
          if (match) {
            next.active = true;
            next.decorationId = prev.decorationId
              ? prev.decorationId
              : decorationId;
            next.range = match.range;
            next.query = match.query;
            next.text = match.text;
          } else {
            next.active = false;
          }
        } else {
          next.active = false;
        }

        // Make sure to empty the range if suggestion is inactive
        if (!next.active) {
          next.decorationId = null;
          next.range = {};
          next.query = null;
          next.text = null;
        }

        return next;
      }
    },

    props: {
      // Call the keydown hook if suggestion is active.
      handleKeyDown(view, event) {
        const { active, range } = this.getState(view.state);

        if (!active) return false;

        return onKeyDown({ view, event, range });
      },

      // Setup decorator on the currently active suggestion.
      decorations(editorState) {
        const { active, range, decorationId } = this.getState(editorState);

        if (!active) return null;

        return DecorationSet.create(editorState.doc, [
          Decoration.inline(range.from, range.to, {
            nodeName: "span",
            class: suggestionClass,
            "data-decoration-id": decorationId
          })
        ]);
      }
    }
  });
}

Hope that gives you or anyone else some further direction.

@softwareguy74
Copy link

I have opened a feature request for something like this. I would like to also suggest this be extended to be able to drag and drop from an arbitrary source component (such as a TreeView node, for example) and drop into specific location in text and have it result in a predefined "Suggestion". This suggestion could then be rearranged by dragging it around with the mouse or deleted by clicking an "X" icon in it.

@asseti6
Copy link

asseti6 commented Jun 10, 2019

@softwareguy74 suggestions in the above example can be set to draggable as is within the document. As for dragging from outside the editor itself, we currently support that in our application by having the dropped variable simply insert the proper text into the editor.

@douglasg14b
Copy link

Oh boy that's quite a bit!

Would be great to see this supported directly, so it can be simply configured.

@hendrikgaffo
Copy link

@asseti6 I have tried to get it working with your solution, but unfortunately without success. I know it has been quite a while since you replied to this issue, but could you give us a CodeSandbox or Repo URL with a working example? I would really appreciate it.

@hanspagel hanspagel mentioned this issue Sep 16, 2020
6 tasks
@bbbford
Copy link

bbbford commented Nov 13, 2020

Thanks for the guidance @asseti6 !

I have successfully implemented a variable solution where the user selects the variable by name, and tiptap displays the variable value inside of the document. The variable display within the document is reactive.

For those wanting to display the value in the document, I would suggest - rather than doing a replace as below:

this.editor.setContent(this.editor.getHTML().replace(new RegExp(<span class=("|"([^"]))variable("|([^"])").*?([^<]+)%%${variable.name}, "g"), variable.value));

Use your vuex store instead:

  // return a vue component
  // this can be an object or an imported component
  get view() {
    return {
      // there are some props available
      // `node` is a Prosemirror Node Object
      // `updateAttrs` is a function to update attributes defined in `schema`
      // `view` is the ProseMirror view instance
      // `options` is an array of your extension options
      // `selected` is a boolean which is true when selected
      // `editor` is a reference to the TipTap editor instance
      // `getPos` is a function to retrieve the start position of the node
      // `decorations` is an array of decorations around the node
      props: [ 'node', 'updateAttrs', 'view', 'options' ],
      computed: {
        label: {
          get() {
             // Get your label from vuex store here
          },
          set(label) {
            this.updateAttrs({
              label: label
            });
          }
        },
      },
      template: `
          <span :class="options.variableClass" :data-variable-id="node.attrs.id">{{label}}</span>
      `
    }

@hendrikgaffo
Copy link

@bbbford Very neat that you managed to get it working. Would you mind sharing a repo with us? That would be very, very helpful.

@hanspagel
Copy link
Contributor

Thanks for the suggestion! And thanks for the code snippets. It’s added to the list of community extensions in #819 and we consider to add it to the core in tiptap v2 at some point. I’m closing this here for now. ✌️

@andrews05
Copy link

@hanspagel Is this still under consideration for v2 core?

@EthosLuke
Copy link

Does anyone have a working example using Livewire and/or AlpineJS?

@andrews05
Copy link

andrews05 commented Mar 9, 2023

TinyMCE has an excellent Merge Tags feature: https://www.tiny.cloud/docs/tinymce/6/mergetags/
Note that the input/output works with the raw text form of the variable without requiring spans etc, so you don't have to do any transformations yourself like what @bbbford showed.
This is exactly what I'd want for TipTap - afaik it's not currently possible for an extension to achieve this?

@AlonMiz
Copy link

AlonMiz commented Jun 23, 2023

TinyMCE has an excellent Merge Tags feature: https://www.tiny.cloud/docs/tinymce/6/mergetags/ Note that the input/output works with the raw text form of the variable without requiring spans etc, so you don't have to do any transformations yourself like what @bbbford showed. This is exactly what I'd want for TipTap - afaik it's not currently possible for an extension to achieve this?

i have the exact same requirement as the mergetags. we would probably have to move to TinyMCE just for this. really liked tiptap though :/

@asseti6
Copy link

asseti6 commented Jul 11, 2023

I am going to need this same feature again for a new project. I'll update the original code as a node extension along with @bbbford's improvements and share it with the community. @hanspagel what the best way to share that for review and for the community?

It will be towards the end of the month before I get to it.

@Acetyld
Copy link

Acetyld commented Dec 5, 2023

@asseti6 any progres =) <3

@aehlke
Copy link

aehlke commented Dec 5, 2023

@hanspagel is there a way to recommend this for consideration again? thanks... sorry for the ping

@geekyayush
Copy link

Hey @asseti6 , sorry for pinging. Have you released the code for it?

@joaomirandas
Copy link

Hey folks,

I spend too much time creating this extension, thats an must have for a kind of application that i made to tiptap, after so many hours i decided to share what i've created. cheers 🚀

@talqui-oss/tiptap-extension-variable

Captura de Tela 2024-01-11 às 02 09 40

@Haraldson
Copy link

@joaomirandas Getting atom nodes to appear in the editor content isn’t really that hard – did you solve how to define the data source/values and get the values reflected?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Type: Feature The issue or pullrequest is a new feature
Projects
None yet
Development

No branches or pull requests