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!
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!
@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.
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.
@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.
Oh boy that's quite a bit!
Would be great to see this supported directly, so it can be simply configured.
@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.
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(, "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>
`
}
@bbbford Very neat that you managed to get it working. Would you mind sharing a repo with us? That would be very, very helpful.
Thanks for the suggestion! And thanks for the code snippets. It鈥檚 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鈥檓 closing this here for now. 鉁岋笍
Most helpful comment
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.