import { Mark } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { schema } from "./schema";
import { key } from "./plugins";
import { TooltipPosition, CursorPosition } from "./tooltip.types";
export class Tooltip {
view: EditorView;
tooltip: HTMLDivElement;
bold: HTMLButtonElement;
italic: HTMLButtonElement;
strike: HTMLButtonElement;
code: HTMLButtonElement;
options: TooltipPosition;
/// @internal
constructor(view: EditorView, options: TooltipPosition) {
this.options = options;
this.view = view;
this.tooltip = document.createElement("div");
this.tooltip.className = "tooltip";
this.bold = document.createElement("button");
this.italic = document.createElement("button");
this.strike = document.createElement("button");
this.code = document.createElement("button");
this.initButtons();
if (view.dom.parentNode) view.dom.parentNode.appendChild(this.tooltip);
this.update(view, null);
}
// initialize the tooltip
initButtons() {
const boldElem = document.createElement("strong");
boldElem.textContent = "B";
this.bold.setAttribute("type", "button");
this.bold.append(boldElem);
this.bold.addEventListener("mousedown", (event) =>
this.setStyle(event, "strong")
);
const italicElem = document.createElement("em");
italicElem.innerHTML = `I`;
this.italic.setAttribute("type", "button");
this.italic.append(italicElem);
this.italic.addEventListener("mousedown", (event) =>
this.setStyle(event, "em")
);
const strikeElem = document.createElement("s");
strikeElem.innerHTML = `S`;
this.strike.setAttribute("type", "button");
this.strike.append(strikeElem);
this.strike.addEventListener("mousedown", (event) =>
this.setStyle(event, "s")
);
const codeElem = document.createElement("strong");
codeElem.innerHTML = `</>`;
this.code.setAttribute("type", "button");
this.code.append(codeElem);
this.code.addEventListener("mousedown", (event) =>
this.setStyle(event, "code")
);
this.tooltip.appendChild(this.bold);
this.tooltip.appendChild(this.italic);
this.tooltip.appendChild(this.strike);
this.tooltip.appendChild(this.code);
}
// this method handles events on the tooltip buttons
setStyle(event: MouseEvent, type: "strong" | "em" | "s" | "code") {
event.preventDefault();
const { from, to, $from } = this.view.state.selection;
let setOfMarks: Set | undefined;
if ($from.nodeAfter?.marks)
setOfMarks = this.getMarksInSelection($from.nodeAfter.marks);
const tr = this.view.state.tr;
if (setOfMarks && setOfMarks.has(type)) {
const newTransaction = tr.removeMark(
from,
to,
schema.marks[type].create()
);
this.view.dispatch(newTransaction);
return;
}
const newTransaction = tr.addMark(from, to, schema.marks[type].create());
this.view.dispatch(newTransaction);
}
// lists the currently active marks in the selected text (if any)
getMarksInSelection(marks: readonly Mark[]) {
const setOfMarks = new Set();
marks.forEach((mark) => {
setOfMarks.add(mark.type.name);
});
return setOfMarks;
}
// toggle tooltip buttons active or inactive based on results from
// getMarksInSelection
toggleActiveButtonClass(state: EditorState) {
if (state.selection.$from.nodeAfter?.marks) {
const setOfMarks = this.getMarksInSelection(
state.selection.$from.nodeAfter?.marks
);
// remove all ".active" classes
this.bold.classList.remove("active");
this.italic.classList.remove("active");
this.strike.classList.remove("active");
this.code.classList.remove("active");
// re-apply active classes
setOfMarks.forEach((name) => {
switch (name) {
case "strong":
this.bold.classList.add("active");
break;
case "em":
this.italic.classList.add("active");
break;
case "s":
this.strike.classList.add("active");
break;
case "code":
this.code.classList.add("active");
break;
}
});
}
}
// plugin update function runs when view updates
update(view: EditorView, lastState: EditorState | null) {
this.view = view; // update the view
const state = view.state;
const focused = key.getState(state);
// Don't do anything if the document/selection didn't change
if (
lastState &&
lastState.doc.eq(state.doc) &&
lastState.selection.eq(state.selection) &&
focused === key.getState(lastState)
)
return;
// Hide the tooltip if the selection is empty
if (state.selection.empty || !focused) {
this.tooltip.style.display = "none";
return;
}
this.toggleActiveButtonClass(state);
// Otherwise, reposition it and update its content
this.tooltip.style.display = "";
const { from, to } = state.selection;
// These are in screen coordinates
const start = view.coordsAtPos(from),
end = view.coordsAtPos(to);
this.setPosition(this.options.position, this.options.distance, start, end);
}
// set the tooltip position based on configuration
setPosition(
position: TooltipPosition["position"],
distance: TooltipPosition["distance"],
start: CursorPosition,
end: CursorPosition
) {
// The box in which the tooltip is positioned, to use as base
const box = this.tooltip.offsetParent!.getBoundingClientRect();
if (position === "TOP") {
const top = box.bottom + distance - start.top;
this.tooltip.style.bottom = top + "px";
const center =
(start.left + end.right) / 2 - this.tooltip.offsetWidth / 2 - box.left;
this.tooltip.style.left = center + "px";
} else if (position === "BOTTOM") {
this.tooltip.style.bottom =
box.bottom -
(start.bottom + this.tooltip.offsetHeight + distance) +
"px";
const center =
(start.left + end.right) / 2 - this.tooltip.offsetWidth / 2 - box.left;
this.tooltip.style.left = center + "px";
} else if (position === "LEFT") {
const left = start.left - this.tooltip.offsetWidth - box.left - distance;
const verticalCenter =
box.bottom -
start.top -
(start.bottom - start.top) / 2 -
this.tooltip.offsetHeight / 2;
this.tooltip.style.left = left + "px";
this.tooltip.style.bottom = verticalCenter + "px";
} else {
const right = end.right + distance - box.left;
const verticalCenter =
box.bottom -
start.top -
(start.bottom - start.top) / 2 -
this.tooltip.offsetHeight / 2;
this.tooltip.style.left = right + "px";
this.tooltip.style.bottom = verticalCenter + "px";
}
}
// destroy tooltip when EditorState is refreshed due to reconfig
// or when editor is destroyed
destroy() {
this.tooltip.remove();
}
}