tiptap
Version:
A rich-text editor for Vue.js
1,638 lines (1,370 loc) • 39.7 kB
JavaScript
/*!
* tiptap v1.32.1
* (c) 2021 überdosis GbR (limited liability)
* @license MIT
*/
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
export { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { Schema, DOMParser, DOMSerializer } from 'prosemirror-model';
import { dropCursor } from 'prosemirror-dropcursor';
import { gapCursor } from 'prosemirror-gapcursor';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap } from 'prosemirror-commands';
import { inputRules, undoInputRule } from 'prosemirror-inputrules';
import { getMarkRange, markIsActive, getMarkAttrs, nodeIsActive, getNodeAttrs } from 'tiptap-utils';
import Vue from 'vue';
import { setBlockType } from 'tiptap-commands';
function camelCase (str) {
return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => index === 0 ? word.toLowerCase() : word.toUpperCase()).replace(/\s+/g, '');
}
class ComponentView {
constructor(component, {
editor,
extension,
parent,
node,
view,
decorations,
getPos
}) {
this.component = component;
this.editor = editor;
this.extension = extension;
this.parent = parent;
this.node = node;
this.view = view;
this.decorations = decorations;
this.isNode = !!this.node.marks;
this.isMark = !this.isNode;
this.getPos = this.isMark ? this.getMarkPos : getPos;
this.captureEvents = true;
this.dom = this.createDOM();
this.contentDOM = this.vm.$refs.content;
}
createDOM() {
const Component = Vue.extend(this.component);
const props = {
editor: this.editor,
node: this.node,
view: this.view,
getPos: () => this.getPos(),
decorations: this.decorations,
selected: false,
options: this.extension.options,
updateAttrs: attrs => this.updateAttrs(attrs)
};
if (typeof this.extension.setSelection === 'function') {
this.setSelection = this.extension.setSelection;
}
if (typeof this.extension.update === 'function') {
this.update = this.extension.update;
}
this.vm = new Component({
parent: this.parent,
propsData: props
}).$mount();
return this.vm.$el;
}
update(node, decorations) {
if (node.type !== this.node.type) {
return false;
}
if (node === this.node && this.decorations === decorations) {
return true;
}
this.node = node;
this.decorations = decorations;
this.updateComponentProps({
node,
decorations
});
return true;
}
updateComponentProps(props) {
if (!this.vm._props) {
return;
} // Update props in component
// TODO: Avoid mutating a prop directly.
// Maybe there is a better way to do this?
const originalSilent = Vue.config.silent;
Vue.config.silent = true;
Object.entries(props).forEach(([key, value]) => {
this.vm._props[key] = value;
}); // this.vm._props.node = node
// this.vm._props.decorations = decorations
Vue.config.silent = originalSilent;
}
updateAttrs(attrs) {
if (!this.view.editable) {
return;
}
const {
state
} = this.view;
const {
type
} = this.node;
const pos = this.getPos();
const newAttrs = { ...this.node.attrs,
...attrs
};
const transaction = this.isMark ? state.tr.removeMark(pos.from, pos.to, type).addMark(pos.from, pos.to, type.create(newAttrs)) : state.tr.setNodeMarkup(pos, null, newAttrs);
this.view.dispatch(transaction);
} // prevent a full re-render of the vue component on update
// we'll handle prop updates in `update()`
ignoreMutation(mutation) {
// allow leaf nodes to be selected
if (mutation.type === 'selection') {
return false;
}
if (!this.contentDOM) {
return true;
}
return !this.contentDOM.contains(mutation.target);
} // disable (almost) all prosemirror event listener for node views
stopEvent(event) {
if (typeof this.extension.stopEvent === 'function') {
return this.extension.stopEvent(event);
}
const draggable = !!this.extension.schema.draggable; // support a custom drag handle
if (draggable && event.type === 'mousedown') {
const dragHandle = event.target.closest && event.target.closest('[data-drag-handle]');
const isValidDragHandle = dragHandle && (this.dom === dragHandle || this.dom.contains(dragHandle));
if (isValidDragHandle) {
this.captureEvents = false;
document.addEventListener('dragend', () => {
this.captureEvents = true;
}, {
once: true
});
}
}
const isCopy = event.type === 'copy';
const isPaste = event.type === 'paste';
const isCut = event.type === 'cut';
const isDrag = event.type.startsWith('drag') || event.type === 'drop';
if (draggable && isDrag || isCopy || isPaste || isCut) {
return false;
}
return this.captureEvents;
}
selectNode() {
this.updateComponentProps({
selected: true
});
}
deselectNode() {
this.updateComponentProps({
selected: false
});
}
getMarkPos() {
const pos = this.view.posAtDOM(this.dom);
const resolvedPos = this.view.state.doc.resolve(pos);
const range = getMarkRange(resolvedPos, this.node.type);
return range;
}
destroy() {
this.vm.$destroy();
}
}
class Emitter {
// Add an event listener for given event
on(event, fn) {
this._callbacks = this._callbacks || {}; // Create namespace for this event
if (!this._callbacks[event]) {
this._callbacks[event] = [];
}
this._callbacks[event].push(fn);
return this;
}
emit(event, ...args) {
this._callbacks = this._callbacks || {};
const callbacks = this._callbacks[event];
if (callbacks) {
callbacks.forEach(callback => callback.apply(this, args));
}
return this;
} // Remove event listener for given event.
// If fn is not provided, all event listeners for that event will be removed.
// If neither is provided, all event listeners will be removed.
off(event, fn) {
if (!arguments.length) {
this._callbacks = {};
} else {
// event listeners for the given event
const callbacks = this._callbacks ? this._callbacks[event] : null;
if (callbacks) {
if (fn) {
this._callbacks[event] = callbacks.filter(cb => cb !== fn); // remove specific handler
} else {
delete this._callbacks[event]; // remove all handlers
}
}
}
return this;
}
}
class Extension {
constructor(options = {}) {
this.options = { ...this.defaultOptions,
...options
};
}
init() {
return null;
}
bindEditor(editor = null) {
this.editor = editor;
}
get name() {
return null;
}
get type() {
return 'extension';
}
get defaultOptions() {
return {};
}
get plugins() {
return [];
}
inputRules() {
return [];
}
pasteRules() {
return [];
}
keys() {
return {};
}
}
class ExtensionManager {
constructor(extensions = [], editor) {
extensions.forEach(extension => {
extension.bindEditor(editor);
extension.init();
});
this.extensions = extensions;
}
get nodes() {
return this.extensions.filter(extension => extension.type === 'node').reduce((nodes, {
name,
schema
}) => ({ ...nodes,
[name]: schema
}), {});
}
get options() {
const {
view
} = this;
return this.extensions.reduce((nodes, extension) => ({ ...nodes,
[extension.name]: new Proxy(extension.options, {
set(obj, prop, value) {
const changed = obj[prop] !== value;
Object.assign(obj, {
[prop]: value
});
if (changed) {
view.updateState(view.state);
}
return true;
}
})
}), {});
}
get marks() {
return this.extensions.filter(extension => extension.type === 'mark').reduce((marks, {
name,
schema
}) => ({ ...marks,
[name]: schema
}), {});
}
get plugins() {
return this.extensions.filter(extension => extension.plugins).reduce((allPlugins, {
plugins
}) => [...allPlugins, ...plugins], []);
}
keymaps({
schema
}) {
const extensionKeymaps = this.extensions.filter(extension => ['extension'].includes(extension.type)).filter(extension => extension.keys).map(extension => extension.keys({
schema
}));
const nodeMarkKeymaps = this.extensions.filter(extension => ['node', 'mark'].includes(extension.type)).filter(extension => extension.keys).map(extension => extension.keys({
type: schema[`${extension.type}s`][extension.name],
schema
}));
return [...extensionKeymaps, ...nodeMarkKeymaps].map(keys => keymap(keys));
}
inputRules({
schema,
excludedExtensions
}) {
if (!(excludedExtensions instanceof Array) && excludedExtensions) return [];
const allowedExtensions = excludedExtensions instanceof Array ? this.extensions.filter(extension => !excludedExtensions.includes(extension.name)) : this.extensions;
const extensionInputRules = allowedExtensions.filter(extension => ['extension'].includes(extension.type)).filter(extension => extension.inputRules).map(extension => extension.inputRules({
schema
}));
const nodeMarkInputRules = allowedExtensions.filter(extension => ['node', 'mark'].includes(extension.type)).filter(extension => extension.inputRules).map(extension => extension.inputRules({
type: schema[`${extension.type}s`][extension.name],
schema
}));
return [...extensionInputRules, ...nodeMarkInputRules].reduce((allInputRules, inputRules) => [...allInputRules, ...inputRules], []);
}
pasteRules({
schema,
excludedExtensions
}) {
if (!(excludedExtensions instanceof Array) && excludedExtensions) return [];
const allowedExtensions = excludedExtensions instanceof Array ? this.extensions.filter(extension => !excludedExtensions.includes(extension.name)) : this.extensions;
const extensionPasteRules = allowedExtensions.filter(extension => ['extension'].includes(extension.type)).filter(extension => extension.pasteRules).map(extension => extension.pasteRules({
schema
}));
const nodeMarkPasteRules = allowedExtensions.filter(extension => ['node', 'mark'].includes(extension.type)).filter(extension => extension.pasteRules).map(extension => extension.pasteRules({
type: schema[`${extension.type}s`][extension.name],
schema
}));
return [...extensionPasteRules, ...nodeMarkPasteRules].reduce((allPasteRules, pasteRules) => [...allPasteRules, ...pasteRules], []);
}
commands({
schema,
view
}) {
return this.extensions.filter(extension => extension.commands).reduce((allCommands, extension) => {
const {
name,
type
} = extension;
const commands = {};
const value = extension.commands({
schema,
...(['node', 'mark'].includes(type) ? {
type: schema[`${type}s`][name]
} : {})
});
const apply = (cb, attrs) => {
if (!view.editable) {
return false;
}
view.focus();
return cb(attrs)(view.state, view.dispatch, view);
};
const handle = (_name, _value) => {
if (Array.isArray(_value)) {
commands[_name] = attrs => _value.forEach(callback => apply(callback, attrs));
} else if (typeof _value === 'function') {
commands[_name] = attrs => apply(_value, attrs);
}
};
if (typeof value === 'object') {
Object.entries(value).forEach(([commandName, commandValue]) => {
handle(commandName, commandValue);
});
} else {
handle(name, value);
}
return { ...allCommands,
...commands
};
}, {});
}
}
function injectCSS (css) {
if (process.env.NODE_ENV !== 'test') {
const style = document.createElement('style');
style.type = 'text/css';
style.textContent = css;
const {
head
} = document;
const {
firstChild
} = head;
if (firstChild) {
head.insertBefore(style, firstChild);
} else {
head.appendChild(style);
}
}
}
class Mark extends Extension {
constructor(options = {}) {
super(options);
}
get type() {
return 'mark';
}
get view() {
return null;
}
get schema() {
return null;
}
command() {
return () => {};
}
}
function minMax(value = 0, min = 0, max = 0) {
return Math.min(Math.max(parseInt(value, 10), min), max);
}
class Node extends Extension {
constructor(options = {}) {
super(options);
}
get type() {
return 'node';
}
get view() {
return null;
}
get schema() {
return null;
}
command() {
return () => {};
}
}
class Doc extends Node {
get name() {
return 'doc';
}
get schema() {
return {
content: 'block+'
};
}
}
class Paragraph extends Node {
get name() {
return 'paragraph';
}
get schema() {
return {
content: 'inline*',
group: 'block',
draggable: false,
parseDOM: [{
tag: 'p'
}],
toDOM: () => ['p', 0]
};
}
commands({
type
}) {
return () => setBlockType(type);
}
}
class Text extends Node {
get name() {
return 'text';
}
get schema() {
return {
group: 'inline'
};
}
}
var css = ".ProseMirror {\n position: relative;\n}\n\n.ProseMirror {\n word-wrap: break-word;\n white-space: pre-wrap;\n -webkit-font-variant-ligatures: none;\n font-variant-ligatures: none;\n}\n\n.ProseMirror pre {\n white-space: pre-wrap;\n}\n\n.ProseMirror-gapcursor {\n display: none;\n pointer-events: none;\n position: absolute;\n}\n\n.ProseMirror-gapcursor:after {\n content: \"\";\n display: block;\n position: absolute;\n top: -2px;\n width: 20px;\n border-top: 1px solid black;\n animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;\n}\n\n@keyframes ProseMirror-cursor-blink {\n to {\n visibility: hidden;\n }\n}\n\n.ProseMirror-hideselection *::selection {\n background: transparent;\n}\n\n.ProseMirror-hideselection *::-moz-selection {\n background: transparent;\n}\n\n.ProseMirror-hideselection * {\n caret-color: transparent;\n}\n\n.ProseMirror-focused .ProseMirror-gapcursor {\n display: block;\n}\n";
class Editor extends Emitter {
constructor(options = {}) {
super();
this.defaultOptions = {
editorProps: {},
editable: true,
autoFocus: null,
extensions: [],
content: '',
topNode: 'doc',
emptyDocument: {
type: 'doc',
content: [{
type: 'paragraph'
}]
},
useBuiltInExtensions: true,
disableInputRules: false,
disablePasteRules: false,
dropCursor: {},
enableDropCursor: true,
enableGapCursor: true,
parseOptions: {},
injectCSS: true,
onInit: () => {},
onTransaction: () => {},
onUpdate: () => {},
onFocus: () => {},
onBlur: () => {},
onPaste: () => {},
onDrop: () => {}
};
this.events = ['init', 'transaction', 'update', 'focus', 'blur', 'paste', 'drop'];
this.init(options);
}
init(options = {}) {
this.setOptions({ ...this.defaultOptions,
...options
});
this.focused = false;
this.selection = {
from: 0,
to: 0
};
this.element = document.createElement('div');
this.extensions = this.createExtensions();
this.nodes = this.createNodes();
this.marks = this.createMarks();
this.schema = this.createSchema();
this.plugins = this.createPlugins();
this.keymaps = this.createKeymaps();
this.inputRules = this.createInputRules();
this.pasteRules = this.createPasteRules();
this.view = this.createView();
this.commands = this.createCommands();
this.setActiveNodesAndMarks();
if (this.options.injectCSS) {
injectCSS(css);
}
if (this.options.autoFocus !== null) {
this.focus(this.options.autoFocus);
}
this.events.forEach(name => {
this.on(name, this.options[camelCase(`on ${name}`)] || (() => {}));
});
this.emit('init', {
view: this.view,
state: this.state
}); // give extension manager access to our view
this.extensions.view = this.view;
}
setOptions(options) {
this.options = { ...this.options,
...options
};
if (this.view && this.state) {
this.view.updateState(this.state);
}
}
get builtInExtensions() {
if (!this.options.useBuiltInExtensions) {
return [];
}
return [new Doc(), new Text(), new Paragraph()];
}
get state() {
return this.view ? this.view.state : null;
}
createExtensions() {
return new ExtensionManager([...this.builtInExtensions, ...this.options.extensions], this);
}
createPlugins() {
return this.extensions.plugins;
}
createKeymaps() {
return this.extensions.keymaps({
schema: this.schema
});
}
createInputRules() {
return this.extensions.inputRules({
schema: this.schema,
excludedExtensions: this.options.disableInputRules
});
}
createPasteRules() {
return this.extensions.pasteRules({
schema: this.schema,
excludedExtensions: this.options.disablePasteRules
});
}
createCommands() {
return this.extensions.commands({
schema: this.schema,
view: this.view
});
}
createNodes() {
return this.extensions.nodes;
}
createMarks() {
return this.extensions.marks;
}
createSchema() {
return new Schema({
topNode: this.options.topNode,
nodes: this.nodes,
marks: this.marks
});
}
createState() {
return EditorState.create({
schema: this.schema,
doc: this.createDocument(this.options.content),
plugins: [...this.plugins, inputRules({
rules: this.inputRules
}), ...this.pasteRules, ...this.keymaps, keymap({
Backspace: undoInputRule
}), keymap(baseKeymap), ...(this.options.enableDropCursor ? [dropCursor(this.options.dropCursor)] : []), ...(this.options.enableGapCursor ? [gapCursor()] : []), new Plugin({
key: new PluginKey('editable'),
props: {
editable: () => this.options.editable
}
}), new Plugin({
props: {
attributes: {
tabindex: 0
},
handleDOMEvents: {
focus: (view, event) => {
this.focused = true;
this.emit('focus', {
event,
state: view.state,
view
});
const transaction = this.state.tr.setMeta('focused', true);
this.view.dispatch(transaction);
},
blur: (view, event) => {
this.focused = false;
this.emit('blur', {
event,
state: view.state,
view
});
const transaction = this.state.tr.setMeta('focused', false);
this.view.dispatch(transaction);
}
}
}
}), new Plugin({
props: this.options.editorProps
})]
});
}
createDocument(content, parseOptions = this.options.parseOptions) {
if (content === null) {
return this.schema.nodeFromJSON(this.options.emptyDocument);
}
if (typeof content === 'object') {
try {
return this.schema.nodeFromJSON(content);
} catch (error) {
console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error);
return this.schema.nodeFromJSON(this.options.emptyDocument);
}
}
if (typeof content === 'string') {
const htmlString = `<div>${content}</div>`;
const parser = new window.DOMParser();
const element = parser.parseFromString(htmlString, 'text/html').body.firstElementChild;
return DOMParser.fromSchema(this.schema).parse(element, parseOptions);
}
return false;
}
createView() {
return new EditorView(this.element, {
state: this.createState(),
handlePaste: (...args) => {
this.emit('paste', ...args);
},
handleDrop: (...args) => {
this.emit('drop', ...args);
},
dispatchTransaction: this.dispatchTransaction.bind(this)
});
}
setParentComponent(component = null) {
if (!component) {
return;
}
this.view.setProps({
nodeViews: this.initNodeViews({
parent: component,
extensions: [...this.builtInExtensions, ...this.options.extensions]
})
});
}
initNodeViews({
parent,
extensions
}) {
return extensions.filter(extension => ['node', 'mark'].includes(extension.type)).filter(extension => extension.view).reduce((nodeViews, extension) => {
const nodeView = (node, view, getPos, decorations) => {
const component = extension.view;
return new ComponentView(component, {
editor: this,
extension,
parent,
node,
view,
getPos,
decorations
});
};
return { ...nodeViews,
[extension.name]: nodeView
};
}, {});
}
dispatchTransaction(transaction) {
const newState = this.state.apply(transaction);
this.view.updateState(newState);
this.selection = {
from: this.state.selection.from,
to: this.state.selection.to
};
this.setActiveNodesAndMarks();
this.emit('transaction', {
getHTML: this.getHTML.bind(this),
getJSON: this.getJSON.bind(this),
state: this.state,
transaction
});
if (!transaction.docChanged || transaction.getMeta('preventUpdate')) {
return;
}
this.emitUpdate(transaction);
}
emitUpdate(transaction) {
this.emit('update', {
getHTML: this.getHTML.bind(this),
getJSON: this.getJSON.bind(this),
state: this.state,
transaction
});
}
resolveSelection(position = null) {
if (this.selection && position === null) {
return this.selection;
}
if (position === 'start' || position === true) {
return {
from: 0,
to: 0
};
}
if (position === 'end') {
const {
doc
} = this.state;
return {
from: doc.content.size,
to: doc.content.size
};
}
return {
from: position,
to: position
};
}
focus(position = null) {
if (this.view.focused && position === null || position === false) {
return;
}
const {
from,
to
} = this.resolveSelection(position);
this.setSelection(from, to);
setTimeout(() => this.view.focus(), 10);
}
setSelection(from = 0, to = 0) {
const {
doc,
tr
} = this.state;
const resolvedFrom = minMax(from, 0, doc.content.size);
const resolvedEnd = minMax(to, 0, doc.content.size);
const selection = TextSelection.create(doc, resolvedFrom, resolvedEnd);
const transaction = tr.setSelection(selection);
this.view.dispatch(transaction);
}
blur() {
this.view.dom.blur();
}
getSchemaJSON() {
return JSON.parse(JSON.stringify({
nodes: this.extensions.nodes,
marks: this.extensions.marks
}));
}
getHTML() {
const div = document.createElement('div');
const fragment = DOMSerializer.fromSchema(this.schema).serializeFragment(this.state.doc.content);
div.appendChild(fragment);
return div.innerHTML;
}
getJSON() {
return this.state.doc.toJSON();
}
setContent(content = {}, emitUpdate = false, parseOptions) {
const {
doc,
tr
} = this.state;
const document = this.createDocument(content, parseOptions);
const selection = TextSelection.create(doc, 0, doc.content.size);
const transaction = tr.setSelection(selection).replaceSelectionWith(document, false).setMeta('preventUpdate', !emitUpdate);
this.view.dispatch(transaction);
}
clearContent(emitUpdate = false) {
this.setContent(this.options.emptyDocument, emitUpdate);
}
setActiveNodesAndMarks() {
this.activeMarks = Object.entries(this.schema.marks).reduce((marks, [name, mark]) => ({ ...marks,
[name]: (attrs = {}) => markIsActive(this.state, mark, attrs)
}), {});
this.activeMarkAttrs = Object.entries(this.schema.marks).reduce((marks, [name, mark]) => ({ ...marks,
[name]: getMarkAttrs(this.state, mark)
}), {});
this.activeNodes = Object.entries(this.schema.nodes).reduce((nodes, [name, node]) => ({ ...nodes,
[name]: (attrs = {}) => nodeIsActive(this.state, node, attrs)
}), {});
}
getMarkAttrs(type = null) {
return this.activeMarkAttrs[type];
}
getNodeAttrs(type = null) {
return { ...getNodeAttrs(this.state, this.schema.nodes[type])
};
}
get isActive() {
return Object.entries({ ...this.activeMarks,
...this.activeNodes
}).reduce((types, [name, value]) => ({ ...types,
[name]: (attrs = {}) => value(attrs)
}), {});
}
registerPlugin(plugin = null, handlePlugins) {
const plugins = typeof handlePlugins === 'function' ? handlePlugins(plugin, this.state.plugins) : [plugin, ...this.state.plugins];
const newState = this.state.reconfigure({
plugins
});
this.view.updateState(newState);
}
unregisterPlugin(name = null) {
if (!name || !this.view.docView) {
return;
}
const newState = this.state.reconfigure({
plugins: this.state.plugins.filter(plugin => !plugin.key.startsWith(`${name}$`))
});
this.view.updateState(newState);
}
destroy() {
if (!this.view) {
return;
}
this.view.destroy();
}
}
var EditorContent = {
props: {
editor: {
default: null,
type: Object
}
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor && editor.element) {
this.$nextTick(() => {
this.$el.appendChild(editor.element.firstChild);
editor.setParentComponent(this);
});
}
}
}
},
render(createElement) {
return createElement('div');
},
beforeDestroy() {
this.editor.element = this.$el;
}
};
class Menu {
constructor({
options
}) {
this.options = options;
this.preventHide = false; // the mousedown event is fired before blur so we can prevent it
this.mousedownHandler = this.handleClick.bind(this);
this.options.element.addEventListener('mousedown', this.mousedownHandler, {
capture: true
});
this.blurHandler = () => {
if (this.preventHide) {
this.preventHide = false;
return;
}
this.options.editor.emit('menubar:focusUpdate', false);
};
this.options.editor.on('blur', this.blurHandler);
}
handleClick() {
this.preventHide = true;
}
destroy() {
this.options.element.removeEventListener('mousedown', this.mousedownHandler);
this.options.editor.off('blur', this.blurHandler);
}
}
function MenuBar (options) {
return new Plugin({
key: new PluginKey('menu_bar'),
view(editorView) {
return new Menu({
editorView,
options
});
}
});
}
var EditorMenuBar = {
props: {
editor: {
default: null,
type: Object
}
},
data() {
return {
focused: false
};
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() => {
editor.registerPlugin(MenuBar({
editor,
element: this.$el
}));
this.focused = editor.focused;
editor.on('focus', () => {
this.focused = true;
});
editor.on('menubar:focusUpdate', focused => {
this.focused = focused;
});
});
}
}
}
},
render() {
if (!this.editor) {
return null;
}
return this.$scopedSlots.default({
focused: this.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive,
getMarkAttrs: this.editor.getMarkAttrs.bind(this.editor),
getNodeAttrs: this.editor.getNodeAttrs.bind(this.editor)
});
}
};
function textRange(node, from, to) {
const range = document.createRange();
range.setEnd(node, to == null ? node.nodeValue.length : to);
range.setStart(node, Math.max(from, 0));
return range;
}
function singleRect(object, bias) {
const rects = object.getClientRects();
return !rects.length ? object.getBoundingClientRect() : rects[bias < 0 ? 0 : rects.length - 1];
}
function coordsAtPos(view, pos, end = false) {
const {
node,
offset
} = view.docView.domFromPos(pos);
let side;
let rect;
if (node.nodeType === 3) {
if (end && offset < node.nodeValue.length) {
rect = singleRect(textRange(node, offset - 1, offset), -1);
side = 'right';
} else if (offset < node.nodeValue.length) {
rect = singleRect(textRange(node, offset, offset + 1), -1);
side = 'left';
}
} else if (node.firstChild) {
if (offset < node.childNodes.length) {
const child = node.childNodes[offset];
rect = singleRect(child.nodeType === 3 ? textRange(child) : child, -1);
side = 'left';
}
if ((!rect || rect.top === rect.bottom) && offset) {
const child = node.childNodes[offset - 1];
rect = singleRect(child.nodeType === 3 ? textRange(child) : child, 1);
side = 'right';
}
} else {
rect = node.getBoundingClientRect();
side = 'left';
}
const x = rect[side];
return {
top: rect.top,
bottom: rect.bottom,
left: x,
right: x
};
}
class Menu$1 {
constructor({
options,
editorView
}) {
this.options = { ...{
element: null,
keepInBounds: true,
onUpdate: () => false
},
...options
};
this.editorView = editorView;
this.isActive = false;
this.left = 0;
this.bottom = 0;
this.top = 0;
this.preventHide = false; // the mousedown event is fired before blur so we can prevent it
this.mousedownHandler = this.handleClick.bind(this);
this.options.element.addEventListener('mousedown', this.mousedownHandler, {
capture: true
});
this.focusHandler = ({
view
}) => {
this.update(view);
};
this.options.editor.on('focus', this.focusHandler);
this.blurHandler = ({
event
}) => {
if (this.preventHide) {
this.preventHide = false;
return;
}
this.hide(event);
};
this.options.editor.on('blur', this.blurHandler);
}
handleClick() {
this.preventHide = true;
}
update(view, lastState) {
const {
state
} = view;
if (view.composing) {
return;
} // Don't do anything if the document/selection didn't change
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return;
} // Hide the tooltip if the selection is empty
if (state.selection.empty) {
this.hide();
return;
} // Otherwise, reposition it and update its content
const {
from,
to
} = state.selection; // These are in screen coordinates
// We can't use EditorView.cordsAtPos here because it can't handle linebreaks correctly
// See: https://github.com/ProseMirror/prosemirror-view/pull/47
const start = coordsAtPos(view, from);
const end = coordsAtPos(view, to, true); // The box in which the tooltip is positioned, to use as base
const parent = this.options.element.offsetParent;
if (!parent) {
this.hide();
return;
}
const box = parent.getBoundingClientRect();
const el = this.options.element.getBoundingClientRect(); // Find a center-ish x position from the selection endpoints (when
// crossing lines, end may be more to the left)
const left = (start.left + end.left) / 2 - box.left; // Keep the menuBubble in the bounding box of the offsetParent i
this.left = Math.round(this.options.keepInBounds ? Math.min(box.width - el.width / 2, Math.max(left, el.width / 2)) : left);
this.bottom = Math.round(box.bottom - start.top);
this.top = Math.round(end.bottom - box.top);
this.isActive = true;
this.sendUpdate();
}
sendUpdate() {
this.options.onUpdate({
isActive: this.isActive,
left: this.left,
bottom: this.bottom,
top: this.top
});
}
hide(event) {
if (event && event.relatedTarget && this.options.element.parentNode && this.options.element.parentNode.contains(event.relatedTarget)) {
return;
}
this.isActive = false;
this.sendUpdate();
}
destroy() {
this.options.element.removeEventListener('mousedown', this.mousedownHandler);
this.options.editor.off('focus', this.focusHandler);
this.options.editor.off('blur', this.blurHandler);
}
}
function MenuBubble (options) {
return new Plugin({
key: new PluginKey('menu_bubble'),
view(editorView) {
return new Menu$1({
editorView,
options
});
}
});
}
var EditorMenuBubble = {
props: {
editor: {
default: null,
type: Object
},
keepInBounds: {
default: true,
type: Boolean
}
},
data() {
return {
menu: {
isActive: false,
left: 0,
bottom: 0
}
};
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() => {
editor.registerPlugin(MenuBubble({
editor,
element: this.$el,
keepInBounds: this.keepInBounds,
onUpdate: menu => {
// the second check ensures event is fired only once
if (menu.isActive && this.menu.isActive === false) {
this.$emit('show', menu);
} else if (!menu.isActive && this.menu.isActive === true) {
this.$emit('hide', menu);
}
this.menu = menu;
}
}));
});
}
}
}
},
render() {
if (!this.editor) {
return null;
}
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive,
getMarkAttrs: this.editor.getMarkAttrs.bind(this.editor),
getNodeAttrs: this.editor.getNodeAttrs.bind(this.editor),
menu: this.menu
});
},
beforeDestroy() {
this.editor.unregisterPlugin('menu_bubble');
}
};
class Menu$2 {
constructor({
options,
editorView
}) {
this.options = { ...{
resizeObserver: true,
element: null,
onUpdate: () => false
},
...options
};
this.preventHide = false;
this.editorView = editorView;
this.isActive = false;
this.top = 0; // the mousedown event is fired before blur so we can prevent it
this.mousedownHandler = this.handleClick.bind(this);
this.options.element.addEventListener('mousedown', this.mousedownHandler, {
capture: true
});
this.focusHandler = ({
view
}) => {
this.update(view);
};
this.options.editor.on('focus', this.focusHandler);
this.blurHandler = ({
event
}) => {
if (this.preventHide) {
this.preventHide = false;
return;
}
this.hide(event);
};
this.options.editor.on('blur', this.blurHandler); // sometimes we have to update the position
// because of a loaded images for example
if (this.options.resizeObserver && window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(() => {
if (this.isActive) {
this.update(this.editorView);
}
});
this.resizeObserver.observe(this.editorView.dom);
}
}
handleClick() {
this.preventHide = true;
}
update(view, lastState) {
const {
state
} = view; // Don't do anything if the document/selection didn't change
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return;
}
if (!state.selection.empty) {
this.hide();
return;
}
const currentDom = view.domAtPos(state.selection.anchor);
const isActive = currentDom.node.innerHTML === '<br>' && currentDom.node.tagName === 'P' && currentDom.node.parentNode === view.dom;
if (!isActive) {
this.hide();
return;
}
const parent = this.options.element.offsetParent;
if (!parent) {
this.hide();
return;
}
const editorBoundings = parent.getBoundingClientRect();
const cursorBoundings = view.coordsAtPos(state.selection.anchor);
const top = cursorBoundings.top - editorBoundings.top;
this.isActive = true;
this.top = top;
this.sendUpdate();
}
sendUpdate() {
this.options.onUpdate({
isActive: this.isActive,
top: this.top
});
}
hide(event) {
if (event && event.relatedTarget && this.options.element.parentNode && this.options.element.parentNode.contains(event.relatedTarget)) {
return;
}
this.isActive = false;
this.sendUpdate();
}
destroy() {
this.options.element.removeEventListener('mousedown', this.mousedownHandler);
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.editorView.dom);
}
this.options.editor.off('focus', this.focusHandler);
this.options.editor.off('blur', this.blurHandler);
}
}
function FloatingMenu (options) {
return new Plugin({
key: new PluginKey('floating_menu'),
view(editorView) {
return new Menu$2({
editorView,
options
});
}
});
}
var EditorFloatingMenu = {
props: {
editor: {
default: null,
type: Object
}
},
data() {
return {
menu: {
isActive: false,
left: 0,
bottom: 0
}
};
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() => {
editor.registerPlugin(FloatingMenu({
editor,
element: this.$el,
onUpdate: menu => {
// the second check ensures event is fired only once
if (menu.isActive && this.menu.isActive === false) {
this.$emit('show', menu);
} else if (!menu.isActive && this.menu.isActive === true) {
this.$emit('hide', menu);
}
this.menu = menu;
}
}));
});
}
}
}
},
render() {
if (!this.editor) {
return null;
}
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive,
getMarkAttrs: this.editor.getMarkAttrs.bind(this.editor),
getNodeAttrs: this.editor.getNodeAttrs.bind(this.editor),
menu: this.menu
});
},
beforeDestroy() {
this.editor.unregisterPlugin('floating_menu');
}
};
export { Doc, Editor, EditorContent, EditorFloatingMenu, EditorMenuBar, EditorMenuBubble, Extension, Mark, Node, Paragraph, Text };