@tiptap/vue-3
Version:
Vue components for tiptap
576 lines (567 loc) • 15.5 kB
JavaScript
// src/Editor.ts
import { Editor as CoreEditor } from "@tiptap/core";
import { customRef, markRaw } from "vue";
function useDebouncedRef(value) {
return customRef((track, trigger) => {
return {
get() {
track();
return value;
},
set(newValue) {
value = newValue;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
trigger();
});
});
}
};
});
}
var Editor = class extends CoreEditor {
constructor(options = {}) {
super(options);
this.contentComponent = null;
this.appContext = null;
this.reactiveState = useDebouncedRef(this.view.state);
this.reactiveExtensionStorage = useDebouncedRef(this.extensionStorage);
this.on("beforeTransaction", ({ nextState }) => {
this.reactiveState.value = nextState;
this.reactiveExtensionStorage.value = this.extensionStorage;
});
return markRaw(this);
}
get state() {
return this.reactiveState ? this.reactiveState.value : this.view.state;
}
get storage() {
return this.reactiveExtensionStorage ? this.reactiveExtensionStorage.value : super.storage;
}
/**
* Register a ProseMirror plugin.
*/
registerPlugin(plugin, handlePlugins) {
const nextState = super.registerPlugin(plugin, handlePlugins);
if (this.reactiveState) {
this.reactiveState.value = nextState;
}
return nextState;
}
/**
* Unregister a ProseMirror plugin.
*/
unregisterPlugin(nameOrPluginKey) {
const nextState = super.unregisterPlugin(nameOrPluginKey);
if (this.reactiveState && nextState) {
this.reactiveState.value = nextState;
}
return nextState;
}
};
// src/EditorContent.ts
import { defineComponent, getCurrentInstance, h, nextTick, onBeforeUnmount, ref, unref, watchEffect } from "vue";
var EditorContent = defineComponent({
name: "EditorContent",
props: {
editor: {
default: null,
type: Object
}
},
setup(props) {
const rootEl = ref();
const instance = getCurrentInstance();
watchEffect(() => {
const editor = props.editor;
if (editor && editor.options.element && rootEl.value) {
nextTick(() => {
var _a;
if (!rootEl.value || !((_a = editor.view.dom) == null ? void 0 : _a.firstChild)) {
return;
}
const element = unref(rootEl.value);
rootEl.value.append(editor.view.dom);
editor.contentComponent = instance.ctx._;
if (instance) {
editor.appContext = {
...instance.appContext,
// Vue internally uses prototype chain to forward/shadow injects across the entire component chain
// so don't use object spread operator or 'Object.assign' and just set `provides` as is on editor's appContext
// @ts-expect-error forward instance's 'provides' into appContext
provides: instance.provides
};
}
editor.setOptions({
element
});
editor.createNodeViews();
});
}
});
onBeforeUnmount(() => {
const editor = props.editor;
if (!editor) {
return;
}
editor.contentComponent = null;
editor.appContext = null;
});
return { rootEl };
},
render() {
return h("div", {
ref: (el) => {
this.rootEl = el;
}
});
}
});
// src/NodeViewContent.ts
import { defineComponent as defineComponent2, h as h2 } from "vue";
var NodeViewContent = defineComponent2({
name: "NodeViewContent",
props: {
as: {
type: String,
default: "div"
}
},
render() {
return h2(this.as, {
style: {
whiteSpace: "pre-wrap"
},
"data-node-view-content": ""
});
}
});
// src/NodeViewWrapper.ts
import { defineComponent as defineComponent3, h as h3 } from "vue";
var NodeViewWrapper = defineComponent3({
name: "NodeViewWrapper",
props: {
as: {
type: String,
default: "div"
}
},
inject: ["onDragStart", "decorationClasses"],
render() {
var _a, _b;
return h3(
this.as,
{
// @ts-ignore
class: this.decorationClasses,
style: {
whiteSpace: "normal"
},
"data-node-view-wrapper": "",
// @ts-ignore (https://github.com/vuejs/vue-next/issues/3031)
onDragstart: this.onDragStart
},
(_b = (_a = this.$slots).default) == null ? void 0 : _b.call(_a)
);
}
});
// src/useEditor.ts
import { onBeforeUnmount as onBeforeUnmount2, onMounted, shallowRef } from "vue";
var useEditor = (options = {}) => {
const editor = shallowRef();
onMounted(() => {
editor.value = new Editor(options);
});
onBeforeUnmount2(() => {
var _a, _b, _c;
const nodes = (_a = editor.value) == null ? void 0 : _a.view.dom;
const newEl = nodes == null ? void 0 : nodes.cloneNode(true);
(_b = nodes == null ? void 0 : nodes.parentNode) == null ? void 0 : _b.replaceChild(newEl, nodes);
(_c = editor.value) == null ? void 0 : _c.destroy();
});
return editor;
};
// src/VueMarkViewRenderer.ts
import { MarkView } from "@tiptap/core";
import { defineComponent as defineComponent4, h as h5, toRaw } from "vue";
// src/VueRenderer.ts
import { h as h4, markRaw as markRaw2, reactive, render } from "vue";
var VueRenderer = class {
constructor(component, { props = {}, editor }) {
this.editor = editor;
this.component = markRaw2(component);
this.el = document.createElement("div");
this.props = reactive(props);
this.renderedComponent = this.renderComponent();
}
get element() {
return this.renderedComponent.el;
}
get ref() {
var _a, _b, _c, _d;
if ((_b = (_a = this.renderedComponent.vNode) == null ? void 0 : _a.component) == null ? void 0 : _b.exposed) {
return this.renderedComponent.vNode.component.exposed;
}
return (_d = (_c = this.renderedComponent.vNode) == null ? void 0 : _c.component) == null ? void 0 : _d.proxy;
}
renderComponent() {
let vNode = h4(this.component, this.props);
if (this.editor.appContext) {
vNode.appContext = this.editor.appContext;
}
if (typeof document !== "undefined" && this.el) {
render(vNode, this.el);
}
const destroy = () => {
if (this.el) {
render(null, this.el);
}
this.el = null;
vNode = null;
};
return { vNode, destroy, el: this.el ? this.el.firstElementChild : null };
}
updateProps(props = {}) {
Object.entries(props).forEach(([key, value]) => {
this.props[key] = value;
});
this.renderComponent();
}
destroy() {
this.renderedComponent.destroy();
}
};
// src/VueMarkViewRenderer.ts
var markViewProps = {
editor: {
type: Object,
required: true
},
mark: {
type: Object,
required: true
},
extension: {
type: Object,
required: true
},
inline: {
type: Boolean,
required: true
},
view: {
type: Object,
required: true
},
updateAttributes: {
type: Function,
required: true
},
HTMLAttributes: {
type: Object,
required: true
}
};
var MarkViewContent = defineComponent4({
name: "MarkViewContent",
props: {
as: {
type: String,
default: "span"
}
},
render() {
return h5(this.as, {
style: {
whiteSpace: "inherit"
},
"data-mark-view-content": ""
});
}
});
var VueMarkView = class extends MarkView {
constructor(component, props, options) {
super(component, props, options);
const componentProps = { ...props, updateAttributes: this.updateAttributes.bind(this) };
const extendedComponent = defineComponent4({
extends: { ...component },
props: Object.keys(componentProps),
template: this.component.template,
setup: (reactiveProps) => {
var _a;
return (_a = component.setup) == null ? void 0 : _a.call(component, reactiveProps, {
expose: () => void 0
});
},
// Add support for scoped styles
__scopeId: component.__scopeId,
__cssModules: component.__cssModules,
__name: component.__name,
__file: component.__file
});
this.renderer = new VueRenderer(extendedComponent, {
editor: this.editor,
props: componentProps
});
}
get dom() {
return this.renderer.element;
}
get contentDOM() {
return this.dom.querySelector("[data-mark-view-content]");
}
updateAttributes(attrs) {
const unproxiedMark = toRaw(this.mark);
super.updateAttributes(attrs, unproxiedMark);
}
destroy() {
this.renderer.destroy();
}
};
function VueMarkViewRenderer(component, options = {}) {
return (props) => {
if (!props.editor.contentComponent) {
return {};
}
return new VueMarkView(component, props, options);
};
}
// src/VueNodeViewRenderer.ts
import { NodeView } from "@tiptap/core";
import { defineComponent as defineComponent5, provide, ref as ref2 } from "vue";
var nodeViewProps = {
editor: {
type: Object,
required: true
},
node: {
type: Object,
required: true
},
decorations: {
type: Object,
required: true
},
selected: {
type: Boolean,
required: true
},
extension: {
type: Object,
required: true
},
getPos: {
type: Function,
required: true
},
updateAttributes: {
type: Function,
required: true
},
deleteNode: {
type: Function,
required: true
},
view: {
type: Object,
required: true
},
innerDecorations: {
type: Object,
required: true
},
HTMLAttributes: {
type: Object,
required: true
}
};
var VueNodeView = class extends NodeView {
mount() {
const props = {
editor: this.editor,
node: this.node,
decorations: this.decorations,
innerDecorations: this.innerDecorations,
view: this.view,
selected: false,
extension: this.extension,
HTMLAttributes: this.HTMLAttributes,
getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode()
};
const onDragStart = this.onDragStart.bind(this);
this.decorationClasses = ref2(this.getDecorationClasses());
const extendedComponent = defineComponent5({
extends: { ...this.component },
props: Object.keys(props),
template: this.component.template,
setup: (reactiveProps) => {
var _a, _b;
provide("onDragStart", onDragStart);
provide("decorationClasses", this.decorationClasses);
return (_b = (_a = this.component).setup) == null ? void 0 : _b.call(_a, reactiveProps, {
expose: () => void 0
});
},
// add support for scoped styles
// @ts-ignore
// eslint-disable-next-line
__scopeId: this.component.__scopeId,
// add support for CSS Modules
// @ts-ignore
// eslint-disable-next-line
__cssModules: this.component.__cssModules,
// add support for vue devtools
// @ts-ignore
// eslint-disable-next-line
__name: this.component.__name,
// @ts-ignore
// eslint-disable-next-line
__file: this.component.__file
});
this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this);
this.editor.on("selectionUpdate", this.handleSelectionUpdate);
this.renderer = new VueRenderer(extendedComponent, {
editor: this.editor,
props
});
}
/**
* Return the DOM element.
* This is the element that will be used to display the node view.
*/
get dom() {
if (!this.renderer.element || !this.renderer.element.hasAttribute("data-node-view-wrapper")) {
throw Error("Please use the NodeViewWrapper component for your node view.");
}
return this.renderer.element;
}
/**
* Return the content DOM element.
* This is the element that will be used to display the rich-text content of the node.
*/
get contentDOM() {
if (this.node.isLeaf) {
return null;
}
return this.dom.querySelector("[data-node-view-content]");
}
/**
* On editor selection update, check if the node is selected.
* If it is, call `selectNode`, otherwise call `deselectNode`.
*/
handleSelectionUpdate() {
const { from, to } = this.editor.state.selection;
const pos = this.getPos();
if (typeof pos !== "number") {
return;
}
if (from <= pos && to >= pos + this.node.nodeSize) {
if (this.renderer.props.selected) {
return;
}
this.selectNode();
} else {
if (!this.renderer.props.selected) {
return;
}
this.deselectNode();
}
}
/**
* On update, update the React component.
* To prevent unnecessary updates, the `update` option can be used.
*/
update(node, decorations, innerDecorations) {
const rerenderComponent = (props) => {
this.decorationClasses.value = this.getDecorationClasses();
this.renderer.updateProps(props);
};
if (typeof this.options.update === "function") {
const oldNode = this.node;
const oldDecorations = this.decorations;
const oldInnerDecorations = this.innerDecorations;
this.node = node;
this.decorations = decorations;
this.innerDecorations = innerDecorations;
return this.options.update({
oldNode,
oldDecorations,
newNode: node,
newDecorations: decorations,
oldInnerDecorations,
innerDecorations,
updateProps: () => rerenderComponent({ node, decorations, innerDecorations })
});
}
if (node.type !== this.node.type) {
return false;
}
if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) {
return true;
}
this.node = node;
this.decorations = decorations;
this.innerDecorations = innerDecorations;
rerenderComponent({ node, decorations, innerDecorations });
return true;
}
/**
* Select the node.
* Add the `selected` prop and the `ProseMirror-selectednode` class.
*/
selectNode() {
this.renderer.updateProps({
selected: true
});
if (this.renderer.element) {
this.renderer.element.classList.add("ProseMirror-selectednode");
}
}
/**
* Deselect the node.
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
*/
deselectNode() {
this.renderer.updateProps({
selected: false
});
if (this.renderer.element) {
this.renderer.element.classList.remove("ProseMirror-selectednode");
}
}
getDecorationClasses() {
return this.decorations.flatMap((item) => item.type.attrs.class).join(" ");
}
destroy() {
this.renderer.destroy();
this.editor.off("selectionUpdate", this.handleSelectionUpdate);
}
};
function VueNodeViewRenderer(component, options) {
return (props) => {
if (!props.editor.contentComponent) {
return {};
}
const normalizedComponent = typeof component === "function" && "__vccOpts" in component ? component.__vccOpts : component;
return new VueNodeView(normalizedComponent, props, options);
};
}
// src/index.ts
export * from "@tiptap/core";
export {
Editor,
EditorContent,
MarkViewContent,
NodeViewContent,
NodeViewWrapper,
VueMarkView,
VueMarkViewRenderer,
VueNodeViewRenderer,
VueRenderer,
markViewProps,
nodeViewProps,
useEditor
};
//# sourceMappingURL=index.js.map