Skip to content

Instantly share code, notes, and snippets.

@romansp
Created January 30, 2024 00:27
Show Gist options
  • Save romansp/001601cfbb0b4ce29537949730f3faae to your computer and use it in GitHub Desktop.
Save romansp/001601cfbb0b4ce29537949730f3faae to your computer and use it in GitHub Desktop.
Vue TipTap suggestion rendering
import {
Node,
VueNodeViewRenderer,
mergeAttributes,
} from "@tiptap/vue-3";
import { createSuggestionRenderer } from "./suggestionRenderer";
export default Node.creat({
// your mention node declarations here...
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
}).configure({
suggestion: createSuggestionRenderer(MentionList),
});
<script setup lang="ts">
import type { SuggestionProps } from "@tiptap/suggestion";
import SuggestionList from "./SuggestionList.vue";
const props = defineProps<SuggestionProps>();
function onKeyDown() {}
defineExpose({
onKeyDown,
});
</script>
<template>
<SuggestionList
v-bind="$props"
>
<div>Hello popup</div>
</SuggestionList>
</template>
<script setup lang="ts">
import type { VirtualElement } from "@floating-ui/dom";
import type { SuggestionProps } from "@tiptap/suggestion";
import {
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverRoot,
} from "radix-vue";
import { ref, watch, watchEffect } from "vue";
const props = defineProps<SuggestionProps>();
const lastValidClientRect = ref<DOMRect>();
watchEffect(() => {
// store valid client rect, and use it when suggestion list is about to be detached from the DOM
// otherwise list will jump to (0,0)
const clientRect = props.clientRect?.();
if (clientRect) {
lastValidClientRect.value = clientRect;
}
});
const anchor = ref<VirtualElement>({
contextElement: props.editor.options.element,
getBoundingClientRect() {
const clientRect = props.clientRect?.();
return clientRect ?? lastValidClientRect.value ?? {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0,
};
},
});
</script>
<template>
<!-- Wrap in div to workaround tiptap vue rendering single child in teleport -->
<div>
<Teleport
to="body"
>
<PopoverRoot default-open>
<PopoverAnchor :element="anchor as any" />
<PopoverPortal>
<PopoverContent
update-position-strategy="always"
side="bottom"
align="start"
>
<slot />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</Teleport>
</div>
</template>
import type { SuggestionKeyDownProps, SuggestionOptions } from "@tiptap/suggestion";
import { VueRenderer } from "@tiptap/vue-3";
import type { Component } from "vue";
interface SuggestionComponent {
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
}
function isSuggestionComponent(componentRef: unknown): componentRef is SuggestionComponent {
if (typeof componentRef !== "object") return false;
if (componentRef === null) return false;
return true;
}
// tiptap doesn't export suggestion state type, so just declare it ourselves
export interface SuggestionPluginState {
active: boolean;
range: Range;
query: null | string;
text: null | string;
composing: boolean;
decorationId?: string | null;
}
export function createSuggestionRenderer(component: Component): Omit<SuggestionOptions, "editor"> {
return {
render: () => {
let renderer: VueRenderer | null;
function unmount() {
if (!renderer) return;
// capture into local variable, because renderer might be re-assigned while promise still settles
const thisRenderer = renderer;
function cleanup() {
thisRenderer.element.remove();
thisRenderer.destroy();
}
cleanup();
renderer = null;
}
return {
onStart: props => {
renderer = new VueRenderer(component, {
props,
editor: props.editor,
});
props.editor.options.element.appendChild(renderer.element);
},
onUpdate(props) {
renderer?.updateProps(props);
},
onKeyDown(props) {
let handled;
if (isSuggestionComponent(renderer?.ref)) {
handled = renderer?.ref.onKeyDown?.(props);
}
return handled ?? false;
},
onExit() {
unmount();
},
};
},
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment