I got around making a version of custom tooltip dismiss logic.
function hoverTooltip(source, options = {}) {
return {
extension: [
window.CodeMirror.view.ViewPlugin.define(view => new DependencyPlugin(view, source, {hoverTime: options.hoverTime || 300})),
]
}
}
class DependencyPlugin {
constructor(view, source, options) {
this.view = view;
this.source = source;
this.options = options;
this.lastMove = {x: 0, y: 0, target: view.dom, time: 0};
view.dom.addEventListener("mouseleave", this.mouseleave = this.mouseleave.bind(this));
view.dom.addEventListener("mousemove", this.mousemove = this.mousemove.bind(this));
this.hoverTimeout = -1;
this.hoverTime = options.hoverTime;
}
checkHover() {
this.hoverTimeout = -1;
if (this.tooltip) return;
let hovered = Date.now() - this.lastMove.time;
if (hovered < this.hoverTime) {
this.hoverTimeout = setTimeout(() => this.checkHover(), this.hoverTime - hovered);
} else {
this.startHover();
}
}
startHover() {
clearTimeout(this.restartTimeout);
let {view, lastMove} = this;
let desc = view.docView.nearest(lastMove.target);
if (!desc) return;
let pos = view.posAtCoords(lastMove);
if (pos === null) return;
const posCoords = view.coordsAtPos(pos);
if (!posCoords ||
lastMove.y < posCoords.top || lastMove.y > posCoords.bottom ||
lastMove.x < posCoords.left - view.defaultCharacterWidth ||
lastMove.x > posCoords.right + view.defaultCharacterWidth) return;
const line = view.state.doc.lineAt(pos);
if (!this.tooltip) {
const rect = view.dom.getBoundingClientRect();
let scaleX = rect.width / view.dom.offsetWidth;
let scaleY = rect.height / view.dom.offsetHeight;
const tip = this.source(view, pos, 1);
if (!tip) {return;}
this.tooltip = tip.create();
this.tooltip.dom.style.position = "absolute";
this.tooltip.dom.classList.add("cm-tooltip-hover");
this.tooltip.dom.classList.add("cm-tooltip-above");
this.tooltip.dom.style.left = `${(posCoords.left - rect.left) / scaleX}px`;
this.tooltip.dom.style.top = `${(posCoords.top - rect.top) / scaleY}px`;
view.dom.appendChild(this.tooltip.dom);
}
this.tooltipPos = {pos, posCoords, line};
}
endHover() {
this.hoverTimeout = -1;
if (this.tooltip) {
this.tooltip.dom?.remove();
this.tooltip = null;
this.tooltipPos = null;
}
}
update(update) {
if (update.docChanged) {
this.endHover();
}
}
isInTooltip(tooltip, event) {
const tooltipMargin = 4;
let { left, right, top: top2, bottom } = tooltip.getBoundingClientRect();
return event.clientX >= left - tooltipMargin &&
event.clientX <= right + tooltipMargin &&
event.clientY >= top2 - tooltipMargin &&
event.clientY <= bottom + tooltipMargin;
}
distance(pos, move) {
return Math.sqrt((pos.left - move.x) ** 2 + (pos.top - move.y) ** 2);
}
mousemove(event) {
this.lastMove = {x: event.clientX, y: event.clientY, target: event.target, time: Date.now()};
const view = this.view;
if (this.hoverTimeout < 0) {
this.hoverTimeout = setTimeout(() => this.checkHover(), this.hoverTime)
}
if (this.tooltip && !this.isInTooltip(this.tooltip.dom, event)) {
const pos = view.posAtCoords(this.lastMove);
if (pos) {
const lastPos = this.tooltipPos;
const line = view.state.doc.lineAt(pos);
if (this.distance(lastPos.posCoords, this.lastMove) > 30 || lastPos.line.number !== line.number) {
this.endHover();
}
}
}
}
mouseleave(event) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = -1;
let inTooltip = this.tooltip && this.tooltip.dom.contains(event.relatedTarget);
if (!inTooltip) {
this.endHover();
} else {
this.watchTooltipLeave(this.tooltip.dom);
}
}
watchTooltipLeave(tooltip) {
let watch = (event) => {
tooltip.removeEventListener("mouseleave", watch);
}
tooltip.addEventListener("mouseleave", watch);
}
destroy() {
this.endHover();
clearTimeout(this.hoverTimeout);
this.view.dom.removeEventListener("mouseleave", this.mouseleave);
this.view.dom.removeEventListener("mousemove", this.mousemove);
}
}
I started from the “docSizePlugin”, and bring hover related things into it (and remove the doc size part.) The core is similar to HoverPlugin but somewhat simplified.
If you open https://yoshikiohshima.github.io/renkon-pad/?file=https://raw.githubusercontent.com/yoshikiohshima/renkon-pad/refs/heads/main/index.renkon
zoom into some code and hover the mouse for a second, you see the dependency tooltip a bit off to the right, and you can get to it.
(the bottom left CodeMirror instance has the implementation of above Plugin.
There are a few questions:
I removed the use of view.dispatch and this.setHover.of etc. It appears to me that for handling at most one custom tooltip I don’t need them but just store things as properties of my DependencyPlugin instance. Is this an okay approach, or I should use the dispatch logic for some reasons?
When is the destroy method called? It does not appear to be called upon deleting an entire CodeMirror instance that has this plugin. Is this true? That means that some timeout may fire after the instance is gone?
BTW, I think the first semicolon on this line is a typo: view/src/tooltip.ts at 31ffdc5919f3bbc2f3773d2fd06a8ad31247bb39 · codemirror/view · GitHub