Suggestions: Allow more dynamic triggers #6143
Replies: 8 comments
|
@Anatoly03 could you get the most recent tiptap versions and try your custom extension again? |
|
This issue is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 7 days |
|
I think this was a good suggestion, making the char accept also a regex, and should be reopened |
|
+1 from me. I had also considered copying the mention and suggestion code to get this functionality. Would be great if it was built in. |
|
@Anatoly03 did you ever get something like this to work? I have a similar use case, however in my scenario what I would like is a suggestion menu to trigger on the paste of certain links if they match a regex - for example to give the user to embed a link from a site like YouTube that offers OEmbed data. So the workflow would be:
There are two ways I could see this working in Tiptap:
I have been looking at the Here's a sample of what I tried for option 2 so far (I am using Vue if that's relevant: This does show my Vue component for the menu when I use the character - I have also tried just not setting the character option, but then nothing happens at all. Any tips or guidance would be helpful... ideally either there'd be an option as you say to substitute the character for a regex, or at least a way to programmatically open a suggestion menu. The only other idea I had potentially was to use a Bubble Menu instead, since you can show those more easily programmatically since they have a |
|
@Anatoly03 Did you ever get this working? I have the exact same use case. |
|
I'm sorry, due to my studies I haven't touched webdev or Github for too long, I don't know the state-of-the-art of all the frameworks today, but no, when I was trying to make an editor, I couldn't make this particular idea to work. I'm not sure if it existed back when I opened the issue, but it seems there's a way to specify a character; [code 2]; But I believe if a lot of people keep pushing this issue, probably not? /**
* The suggestion options.
* @default {}
* @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
*/
suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, 'editor'> /**
* The character that triggers the suggestion.
* @default '@'
* @example '#'
*/
char?: string |
|
@doulighan @hannojg @mgtcampbell got a custom extension kinda working // autocomplete-extension.ts
import { Extension, Editor, Content } from '@tiptap/core';
import { EditorState, Plugin, PluginKey } from 'prosemirror-state';
import { Node } from '@tiptap/pm/model';
import { EditorView } from 'prosemirror-view';
import { Ref } from 'vue';
import MarkdownEditorAutocomplete from './MarkdownEditorAutocomplete.vue';
/**
* A suggestion item for the autocomplete.
*/
export type SuggestionItem = {
/**
* The label for the suggestion (name of the record).
*/
label: string;
};
export type AutocompleteOptions = {
/**
* Called with the current text before cursor if a suggestion is needed.
* @param text
* @returns A promise that resolves to an array of suggestions or null/empty
* if none.
*/
suggest: (text: string) => Promise<SuggestionItem[] | null>;
/**
* Generate the string sequence that will be inserted into the editor when a
* suggestion is selected.
* @param selectedItem The suggestion item that was selected.
* @returns Raw markdown string to insert into the editor.
*/
replace: (selectedItem: SuggestionItem) => Content | Node;
/**
*
* @param text
* @returns
* @example
*
* {
* // alphanumeric
* extractToken: (text) => text.match(/(\w+)$/).?[1],
* // alphanumeric with underscores and dots
* extractToken: (text) => text.match(/[a-zA-Z0-9_.]+$/).?[1],
* }
*/
extractToken?: (text: string) => string | null;
/**
* A reference to the autocomplete popup component. This is used to
* control the visibility, position and options of the popup.
*/
popup?: Ref<InstanceType<typeof MarkdownEditorAutocomplete> | null>;
};
function triggerAutocomplete(
options: AutocompleteOptions,
editorView: EditorView
) {
return (item: SuggestionItem) =>
({ commands }) => {
// We only apply the autocomplete logic within one element of the editor.
//
// For example `<a href="#">hel</a>lo` with the cursor at the end will autocomplete
// for 'lo', not 'hello'.
//
// The exception is bold, italic and underline.
// For example `<b>hel</>lo` with the cursor at the end will autocomplete
// for 'hello'.
//
// Below $nodes is computed to be the content of the current element after resolving
// the selection position. We only apply the autocomplete logic within this context.
const $from = editorView.state.selection.$from;
// const $parent = $from.parent;
// const $nodes = $parent.content.content;
// Find the currently selected node within the $nodes array.
const $nodeBefore = $from.nodeBefore;
const textBeforeCursor = $nodeBefore?.text ?? '';
// Extract the token from the text before the cursor. This is used to
// further shorten the context range.
const token =
options.extractToken?.(textBeforeCursor) ?? textBeforeCursor;
const rangeFrom = $from.pos - token.length;
const rangeTo = $from.pos;
// Delete the token before the cursor.
commands.deleteRange({ from: rangeFrom, to: rangeTo });
// Insert the selected suggestion into the editor.
commands.insertContent([
{
type: 'text',
marks: [
{
type: 'link',
attrs: {
href: `./${encodeURIComponent(item.label)}.md`,
},
},
],
text: item.label,
},
{
type: 'text',
text: ' ',
},
]);
// Hide the autocomplete popup after selection.
options.popup?.value?.hide();
return true;
};
}
export const ProsemirrorAutocompleteExtension = (
options: AutocompleteOptions,
editor: Editor
) =>
new Plugin({
key: new PluginKey('autocomplete'),
view: (editorView) => {
return {
update: (_editorView: EditorView, _prevState: EditorState) => {
// We only apply the autocomplete logic within one element of the editor.
//
// For example `<a href="#">hel</a>lo` with the cursor at the end will autocomplete
// for 'lo', not 'hello'.
//
// The exception is bold, italic and underline.
// For example `<b>hel</>lo` with the cursor at the end will autocomplete
// for 'hello'.
//
// Below $nodes is computed to be the content of the current element after resolving
// the selection position. We only apply the autocomplete logic within this context.
const $from = editorView.state.selection.$from;
// const $parent = $from.parent;
// const $nodes = $parent.content.content;
// Find the currently selected node within the $nodes array.
const $nodeBefore = $from.nodeBefore;
const textBeforeCursor = $nodeBefore?.text ?? '';
// Extract the token from the text before the cursor. This is used to
// further shorten the context range.
const token =
options.extractToken?.(textBeforeCursor) ??
textBeforeCursor;
// Get the suggestions for the current text before the cursor.
options.suggest(token).then((suggestions) => {
// If we can't find the popup component, we can't show the suggestions.
if (!options.popup?.value) return;
// Get the list of suggestions and propagate them to the
// autocomplete popup component.
options.popup.value.setSuggestions(suggestions ?? []);
options.popup.value.show();
});
},
destroy: () => {
// TODO
},
};
},
});
export const AutocompleteExtension = Extension.create<AutocompleteOptions>({
name: 'autocomplete',
addOptions() {
return {
suggest: async () => {
console.warn('Autocomplete suggest function not set.');
return [];
},
replace: (item) => item.label,
extractToken: (text) => {
const match = text.match(/(\w+)$/);
return match ? match[1] : null;
},
};
},
addCommands() {
return {
autocomplete: triggerAutocomplete(this.options, this.editor),
};
},
addProseMirrorPlugins() {
return [ProsemirrorAutocompleteExtension(this.options, this.editor)];
},
});
export default AutocompleteExtension;<!-- MarkdownEditor.vue -->
<template>
<div class="view-component-article-editor">
<editor-content
class="file-editor-md"
@click="onEditorClick"
:editor="editor"
/>
<markdown-editor-autocomplete
ref="autocompletePopup"
:editor-view="editor.view"
:suggestions="suggestions"
@select="onSuggestionSelect"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { Editor, EditorContent } from '@tiptap/vue-3';
import { Markdown } from '@tiptap/markdown';
import { TaskList, TaskItem } from '@tiptap/extension-list';
// tiptap extensions
import StarterKit from '@tiptap/starter-kit';
import {
AutocompleteExtension,
type SuggestionItem,
} from './autocomplete-extension';
import MarkdownEditorAutocomplete from './MarkdownEditorAutocomplete.vue';
const props = defineProps<{
directory: string | null;
name: string | null;
autocompleteSuggestion: (name: string) => Promise<SuggestionItem[]>;
}>();
const autocompletePopup = ref<InstanceType<
typeof MarkdownEditorAutocomplete
> | null>(null);
const suggestions = ref<SuggestionItem[]>([]);
const ignoreFirstSave = ref(true);
const editor = new Editor({
extensions: [
Markdown,
StarterKit,
TaskList,
TaskItem,
AutocompleteExtension.configure({
suggest: async (name: string) => {
const suggestions = await props.autocompleteSuggestion(name);
return suggestions;
},
replace: (item) => ({
type: 'text',
marks: [
{
type: 'link',
attrs: {
href: `./${encodeURIComponent(item.label)}.md`,
},
},
],
text: item.label,
}),
extractToken: (text) => {
const match = text.match(/[a-zA-Z0-9_.]+$/);
return match ? match[0] : null;
},
popup: autocompletePopup,
}),
],
content: '', // Initial content is empty; will be loaded in onMounted
// content:
// '<h1>STOP!!!</h1><p>IF YOU SEE THIS DO NOT EDIT THIS FILE!!! AN ERROR OCCURED ' +
// 'AND EDITING THIS FILE CAN CAUSE DATA LOSS!!! RELOAD THE APPLICATION!!!</p>',
onUpdate: saveFile,
});
onMounted(async () => {
// Load the file content when the component is mounted.
await loadFile();
autocompletePopup.value?.hide();
// Start listening after mounted.
editor.on('focus', () => {
autocompletePopup.value?.show();
autocompletePopup.value?.realign();
});
editor.on('selectionUpdate', () => {
autocompletePopup.value?.realign();
});
});
watch(
() => props.name,
async (name: string | null) => {
if (!name) return;
// Load the file content when a new file is selected
let bytes = await invoke('get_record_component', {
directory: props.directory,
name,
component: 'article',
});
let content = new TextDecoder().decode(bytes as Uint8Array);
console.debug('load', content);
ignoreFirstSave.value = true;
editor.commands.setContent(content, { contentType: 'markdown' });
}
);
async function loadFile() {
let bytes = await invoke('get_record_component', {
directory: props.directory,
name: props.name,
component: 'article',
});
console.log(bytes);
let content = new TextDecoder().decode(bytes as Uint8Array);
console.debug('load', content);
ignoreFirstSave.value = true;
editor.commands.setContent(content, { contentType: 'markdown' });
}
async function saveFile() {
if (!props.directory) return;
if (!props.name) return;
if (ignoreFirstSave.value) {
ignoreFirstSave.value = false;
return;
}
// Get markdown content from the editor
const content = editor.getMarkdown();
console.debug('save', content);
// Save the file content whenever it changes
await invoke('save_record_component', {
directory: props.directory,
name: props.name,
component: 'article',
content,
});
}
// Call command from autocomplete extension.
async function onSuggestionSelect(item: SuggestionItem) {
editor.commands.autocomplete(item);
}
async function onEditorClick() {
// Focus the editor when the user clicks on it
editor.commands.focus();
}
onUnmounted(() => {
editor.destroy();
});
</script>
<style lang="scss">
.view-component-article-editor {
width: 100%;
height: 100%;
}
.file-editor-md {
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 16px;
.ProseMirror {
outline: none;
}
p {
padding: 0;
margin: 0;
}
label,
input[type='checkbox'] {
display: inline;
}
li * {
display: inline;
}
li[data-checked] {
list-style-type: none;
}
}
</style><!-- MarkdownEditorAutocomplete.vue -->
<template>
<ul
class="autocomplete-popup"
:class="{ show: visible && items.length > 0 }"
:style="{ left: left + 'px', top: top + 'px' }"
>
<li
v-for="record in items"
:key="record.label"
class="autocomplete-item"
:class="{ active: currentSelection?.label == record?.label }"
@mouseenter="currentSelection = record"
@mousedown.prevent="emit('select', record)"
@keydown="emit('select', record)"
>
{{ record.label }}
</li>
</ul>
</template>
<script setup lang="ts">
import { EditorView } from 'prosemirror-view';
import { onMounted, onUnmounted, ref } from 'vue';
import type { SuggestionItem } from './autocomplete-extension';
const props = defineProps<{
editorView: EditorView;
suggestions: SuggestionItem[];
}>();
const emit = defineEmits<{
(e: 'select', suggestion: SuggestionItem): void;
}>();
const items = ref(props.suggestions);
const currentSelection = ref<SuggestionItem | undefined>(props.suggestions[0]);
const visible = ref(false);
const left = ref(0);
const top = ref(0);
function realign() {
const cursor = props.editorView.state.selection.from;
const coords = props.editorView.coordsAtPos(cursor);
left.value = coords.left;
top.value = coords.top + 10; // below cursor line
}
function show() {
realign();
visible.value = true;
}
async function hide() {
await new Promise((resolve) => setTimeout(resolve, 100)); // wait for click event to propagate
visible.value = false;
}
function onKeyDown(event: KeyboardEvent) {
if (!visible.value || items.value.length === 0) return;
// Block default behaviour for these keys
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
}
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
const currentIndex = items.value.findIndex(
(s) => s.label === currentSelection.value?.label
);
const nextIndex =
event.key === 'ArrowDown'
? (currentIndex + 1) % items.value.length
: (currentIndex - 1 + items.value.length) %
items.value.length;
currentSelection.value = items.value[nextIndex];
break;
case 'Enter':
case 'Tab':
if (currentSelection.value) {
emit('select', currentSelection.value);
}
break;
case 'Escape':
event.preventDefault();
hide();
break;
}
}
function setSuggestions(suggestions: SuggestionItem[]) {
items.value = suggestions;
if (suggestions.length > 0) {
currentSelection.value = suggestions[0];
} else {
currentSelection.value = undefined;
}
}
onMounted(() => {
const dom = props.editorView.dom;
dom.addEventListener('focus', show);
dom.addEventListener('blur', hide);
dom.addEventListener('keydown', onKeyDown, { capture: true });
});
onUnmounted(() => {
const dom = props.editorView.dom;
dom.removeEventListener('focus', show);
dom.removeEventListener('blur', hide);
dom.removeEventListener('keydown', onKeyDown, { capture: true });
});
defineExpose({
realign,
show,
hide,
setSuggestions,
});
</script>
<style lang="scss" scoped>
.autocomplete-popup {
position: fixed;
display: none;
flex-direction: column;
min-width: 128px;
padding: 4px 0;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
&.show {
display: flex;
}
li.autocomplete-item {
list-style: none;
padding: 4px 12px;
cursor: pointer;
&:hover,
&.active {
background-color: #f0f0f0;
}
}
}
</style> |
Uh oh!
There was an error while loading. Please reload this page.
What problem are you facing?
I'm looking for a way to use the Suggestions/Mention Plugin, but without the trigger being the
@-character, rather the entire word. For example if you were to start writingA,Alicewould show up. In other words, I'm looking for a more dynamic way to trigger the suggestions. This could include only triggering when two letters of the word where added, or n, or instantly triggering after the first letter.What’s the solution you would like to see?
Making
Suggestion.charregex-based, so@wouldn't be affected, but allowing more freedom for the trigger. You could then allow it to trigger upon words that start with a capital letter[A-Z], or words that start with a capital letter and have two letters[A-Z][a-z], or perhaps words that have exactly three characters[A-Za-z]{3}.What alternatives did you consider?
I tried writing a custom extension, for that I've cloned suggestion.ts, findSuggestionMatch.ts and mention.ts into my repository, to see how they work, however a bug occured in lines 244 and 255 in
suggestion.ts:Property 'getState' does not exist on type 'EditorProps'.ts(2339)that I do not know how to fix. It seemed to be a bug with the plugin's prose mirror being out of date, perhaps?Anything to add? (optional)
No response
Are you sponsoring us?
All reactions