import {
NodeSelection,
Plugin,
PluginKey,
TextSelection,
type EditorState,
type Transaction
} from "prosemirror-state"
import {Decoration, DecorationSet, type EditorView} from "prosemirror-view"
import type {Node as ProseMirrorNode} from "prosemirror-model"
import {COMMENT_ONLY_ROLES, READ_ONLY_ROLES} from "../index.js"
import {MathDialog} from "../dialogs/index.js"
import {elementDisabled} from "../menus/toolbar/model.js"
import {amendTransaction} from "../track/index.js"
import type {Editor} from "../types.js"
const key = new PluginKey("inlineMath")
interface MathDialogLike {
new (editor: Editor): {init(): void}
}
const MathDialogCtor = MathDialog as MathDialogLike
/**
* Check if an inline equation can be activated at the current position.
* Mirrors the toolbar equation-button disabled check.
*/
function canActivateInlineMath(
_state: EditorState,
_$pos: unknown,
editor: Editor
): boolean {
if (editor && elementDisabled(editor, "equation")) {
return false
}
return true
}
/* ────────────────────────────────────────────────────────── */
/* Helpers shared by the inline-editor widget */
/* ────────────────────────────────────────────────────────── */
function getInputCursorPos(input: HTMLElement): number {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) {
return (input.textContent || "").length
}
return sel.getRangeAt(0).startOffset
}
function setInputCursorPos(input: HTMLElement, pos: number): void {
const textNode = input.firstChild
if (!textNode) {
return
}
const range = document.createRange()
const safePos = Math.min(pos, (textNode.textContent || "").length)
range.setStart(textNode, safePos)
range.collapse(true)
const sel = window.getSelection()
if (sel) {
sel.removeAllRanges()
sel.addRange(range)
}
}
interface InlineEditorState {
active: boolean
from: number
query: string
isEdit: boolean
mathNodePos: number
widgetId: string | undefined
cursorAtStart: boolean | undefined
decos: DecorationSet
}
/**
* Build the contenteditable widget that lets the user type raw LaTeX.
* The query includes the leading "$", which is shown in the widget so the
* user always sees "$…latex…”.
*/
function createInlineMathWidget(
editor: Editor,
pluginState: InlineEditorState,
pluginKey: PluginKey
): HTMLElement {
const view = editor.currentView || editor.view
const container = document.createElement("span")
container.className = "inline-math-widget"
const input = document.createElement("span")
input.contentEditable = "plaintext-only"
input.spellcheck = false
input.className = "inline-math-input"
input.textContent = pluginState.query
input.addEventListener("input", () => {
const text = input.textContent || ""
if (!text) {
// Everything including `$` was deleted — remove the node.
view.dispatch(
view.state.tr.setMeta(pluginKey, {action: "cancelDelete"})
)
view.focus()
return
}
if (!text.startsWith("$")) {
// `$` was removed but content remains — restore it.
const cursorPos = getInputCursorPos(input)
input.textContent = "$" + text
setInputCursorPos(input, cursorPos + 1)
}
view.dispatch(
view.state.tr.setMeta(pluginKey, {
action: "updateQuery",
query: input.textContent
})
)
})
input.addEventListener("keypress", event => {
// Stop keypress from reaching ProseMirror
event.stopPropagation()
})
input.addEventListener("keydown", event => {
event.stopPropagation()
const currentState = pluginKey.getState(view.state) as
| InlineEditorState
| undefined
if (!currentState?.active) {
return
}
switch (event.key) {
case "ArrowLeft":
if (getInputCursorPos(input) === 0) {
event.preventDefault()
view.dispatch(
view.state.tr.setMeta(pluginKey, {action: "cancelLeft"})
)
view.focus()
}
break
case "ArrowRight":
if (
getInputCursorPos(input) ===
(input.textContent || "").length
) {
event.preventDefault()
view.dispatch(
view.state.tr.setMeta(pluginKey, {
action: "commitAndRight"
})
)
view.focus()
}
break
case "Enter":
case "Tab":
event.preventDefault()
view.dispatch(
view.state.tr.setMeta(pluginKey, {action: "commit"})
)
view.focus()
break
case "Escape":
event.preventDefault()
view.dispatch(
view.state.tr.setMeta(pluginKey, {action: "cancel"})
)
view.focus()
break
case "Backspace":
// When only `$` remains, delete the whole node.
if ((input.textContent || "").length <= 1) {
event.preventDefault()
view.dispatch(
view.state.tr.setMeta(pluginKey, {
action: "cancelDelete"
})
)
view.focus()
}
break
case "Delete":
// Same as Backspace when only `$` is left.
if ((input.textContent || "").length <= 1) {
event.preventDefault()
view.dispatch(
view.state.tr.setMeta(pluginKey, {
action: "cancelDelete"
})
)
view.focus()
}
break
default:
break
}
})
input.addEventListener("focusout", event => {
const related = event.relatedTarget
if (container.contains(related as globalThis.Node | null)) {
return
}
const myWidgetId = pluginState.widgetId
// Use a tiny timeout so that a click on the container itself
// (e.g. scrollbar) does not accidentally commit.
setTimeout(() => {
const currentState = pluginKey.getState(view.state) as
| InlineEditorState
| undefined
if (currentState?.active && currentState.widgetId === myWidgetId) {
view.dispatch(
view.state.tr.setMeta(pluginKey, {action: "commit"})
)
}
}, 1)
})
container.addEventListener("mousedown", event => {
event.stopPropagation()
})
container.appendChild(input)
// Focus the input and position the cursor after mounting.
setTimeout(() => {
input.focus()
if (pluginState.cursorAtStart === true) {
setInputCursorPos(input, 0)
} else {
setInputCursorPos(input, (input.textContent || "").length)
}
}, 0)
return container
}
/* ────────────────────────────────────────────────────────── */
/* Dropup plugin (inline_math preference OFF) */
/* ────────────────────────────────────────────────────────── */
interface DropupState {
equationNode: ProseMirrorNode | undefined
decos: DecorationSet
selectedIndex: number
}
function dropupPlugin(options: {editor: Editor}): Plugin {
const editor = options.editor
let currentDropUpActions: Array<() => void> = []
const editAccess = ["write", "write-tracked", "review-tracked"].includes(
editor.docInfo.access_rights as string
)
function getEquationNode(state: EditorState): ProseMirrorNode | undefined {
return state.selection instanceof NodeSelection &&
state.selection.node.type.name === "equation"
? state.selection.node
: undefined
}
function createEquationDropUp(
equationNode: ProseMirrorNode,
selectedIndex = -1
): HTMLElement {
const dropUp = document.createElement("span")
dropUp.classList.add("drop-up-outer")
const _latex = equationNode.attrs.equation as string
const requiredPx = editAccess ? 92 : 50
dropUp.innerHTML = `
${
editAccess
? `
-
${gettext("Edit")}
-
${gettext("Remove")}
`
: ""
}
`
currentDropUpActions = []
const setupAction = (
selector: string,
action: () => void
): void => {
const el = dropUp.querySelector(selector)
if (!el) {
return
}
currentDropUpActions.push(action)
el.addEventListener("mousedown", event => {
event.preventDefault()
event.stopImmediatePropagation()
action()
})
}
setupAction(".edit-equation", () => {
// MathDialog reads the NodeSelection from the view, so we rely on
// the equation node already being NodeSelected when the button is
// activated.
const dialog = new MathDialogCtor(editor)
dialog.init()
})
setupAction(".remove-equation", () => {
const view = editor.currentView
const state = view.state
if (state.selection instanceof NodeSelection) {
view.dispatch(state.tr.deleteSelection())
}
})
if (selectedIndex >= 0 && selectedIndex < currentDropUpActions.length) {
const items = Array.from(
dropUp.querySelectorAll(".drop-up-options li")
)
if (items[selectedIndex]) {
items[selectedIndex].classList.add("fw-focused")
}
}
return dropUp
}
function getDecos(state: EditorState, selectedIndex = -1): DecorationSet {
const equationNode = getEquationNode(state)
if (!equationNode) {
return DecorationSet.empty
}
// Place the dropup widget just after the equation node.
const startPos = state.selection.from + equationNode.nodeSize
const dom = createEquationDropUp(equationNode, selectedIndex)
return DecorationSet.create(state.doc, [
Decoration.widget(startPos, dom)
])
}
return new Plugin({
key,
state: {
init(): DropupState {
return {
equationNode: undefined,
decos: DecorationSet.empty,
selectedIndex: -1
}
},
apply(tr, prev, _oldState, state): DropupState {
const newEquationNode = getEquationNode(state)
const meta = tr.getMeta(key)
let {selectedIndex} = prev as DropupState
if (meta?.action === "navigate") {
selectedIndex = meta.index as number
return {
equationNode: newEquationNode,
decos: getDecos(state, selectedIndex),
selectedIndex
}
}
if (newEquationNode === (prev as DropupState).equationNode) {
return {
equationNode: newEquationNode,
decos: (prev as DropupState).decos.map(
tr.mapping,
tr.doc
),
selectedIndex
}
}
// Equation node changed (or deselected): reset selection index.
selectedIndex = -1
return {
equationNode: newEquationNode,
decos: getDecos(state, selectedIndex),
selectedIndex
}
}
},
props: {
handleKeyDown(view: EditorView, event: KeyboardEvent) {
const pluginState = key.getState(view.state) as
| DropupState
| undefined
if (!pluginState?.equationNode) {
return false
}
const totalItems = currentDropUpActions.length
if (totalItems === 0) {
return false
}
const {selectedIndex} = pluginState
if (event.key === "ArrowDown") {
event.preventDefault()
const newIndex =
selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0
view.dispatch(
view.state.tr.setMeta(key, {
action: "navigate",
index: newIndex
})
)
return true
}
if (event.key === "ArrowUp") {
event.preventDefault()
const newIndex =
selectedIndex <= 0 ? totalItems - 1 : selectedIndex - 1
view.dispatch(
view.state.tr.setMeta(key, {
action: "navigate",
index: newIndex
})
)
return true
}
if (
event.key === "Enter" &&
selectedIndex >= 0 &&
selectedIndex < totalItems
) {
event.preventDefault()
currentDropUpActions[selectedIndex]()
return true
}
return false
},
decorations(state: EditorState) {
const pluginState = key.getState(state) as
| DropupState
| undefined
return pluginState?.decos || DecorationSet.empty
}
}
})
}
/* ────────────────────────────────────────────────────────── */
/* Inline editor plugin (inline_math preference ON) */
/* ────────────────────────────────────────────────────────── */
function inlineEditorPlugin(options: {editor: Editor}): Plugin {
const editor = options.editor
return new Plugin({
key,
state: {
init(): InlineEditorState {
return {
active: false,
from: 0,
query: "",
isEdit: false,
mathNodePos: 0,
widgetId: undefined,
cursorAtStart: undefined,
decos: DecorationSet.empty
}
},
apply(tr, prev, _oldState, _newState): InlineEditorState {
let next = {...prev} as InlineEditorState
const meta = tr.getMeta(key)
if (meta?.action === "activate") {
next = {
active: true,
from: meta.from as number,
query: (meta.query as string) || "$",
isEdit: (meta.isEdit as boolean) || false,
mathNodePos: (meta.mathNodePos as number) || 0,
widgetId: Math.random().toString(36).slice(2),
cursorAtStart: meta.cursorAtStart as
| boolean
| undefined,
decos: DecorationSet.empty
}
} else if (meta?.action === "deactivate") {
next = {
active: false,
from: 0,
query: "",
isEdit: false,
mathNodePos: 0,
widgetId: undefined,
cursorAtStart: undefined,
decos: DecorationSet.empty
}
} else if (meta?.action === "updateQuery") {
next.query = meta.query as string
}
if (next.active) {
if (!prev.active || prev.from !== next.from) {
// Position changed or freshly activated: build decorations.
const decoList = [
Decoration.widget(
next.from,
() => createInlineMathWidget(editor, next, key),
{key: "inline-math-widget", side: -1}
)
]
if (next.isEdit) {
// Hide the equation node while the inline editor is open.
decoList.push(
Decoration.node(
next.mathNodePos,
next.mathNodePos + 1,
{class: "hide"}
)
)
}
next.decos = DecorationSet.create(
_newState.doc,
decoList
)
} else if (tr.docChanged) {
next.decos = prev.decos.map(tr.mapping, _newState.doc)
} else {
next.decos = prev.decos
}
} else {
next.decos = DecorationSet.empty
}
return next
}
},
appendTransaction: (trs, oldState, newState) => {
const oldPluginState = key.getState(oldState) as
| InlineEditorState
| undefined
const meta = trs.find(tr => tr.getMeta(key))?.getMeta(key)
/* Transactions appended here are applied by ProseMirror directly
and never pass through EditorView.dispatchTransaction, which is
where amendTransaction normally converts edits into tracked
changes. Run the amendment here so that inline math edits are
tracked like edits made through the math dialog. The plugin's
own meta is attached only after amendment — amendTransaction
ignores transactions carrying foreign metadata. */
const tracked = (tr: Transaction): Transaction =>
amendTransaction(tr, newState, editor).setMeta(key, {
action: "deactivate"
})
/* ── commit / commitAndRight ── */
if (
meta?.action === "commit" ||
meta?.action === "commitAndRight"
) {
if (!oldPluginState?.active) {
return null
}
const query = oldPluginState.query
const latex = query.startsWith("$") ? query.slice(1) : query
if (!latex.trim()) {
// Empty LaTeX: delete the node (edit) or just cancel (new).
if (oldPluginState.isEdit) {
const existingNode = newState.doc.nodeAt(
oldPluginState.mathNodePos
)
const tr = newState.tr.delete(
oldPluginState.mathNodePos,
oldPluginState.mathNodePos +
(existingNode?.nodeSize || 1)
)
tr.setSelection(
TextSelection.create(
tr.doc,
oldPluginState.mathNodePos
)
)
return tracked(tr)
} else {
// Insert the literal "$" and move cursor past it.
const tr = newState.tr.insertText(
"$",
oldPluginState.from
)
tr.setSelection(
TextSelection.create(
tr.doc,
oldPluginState.from + 1
)
)
return tracked(tr)
}
}
const equationNode = newState.schema.nodes.equation.create({
equation: latex
})
if (oldPluginState.isEdit) {
const existingNode = newState.doc.nodeAt(
oldPluginState.mathNodePos
)
const tr = newState.tr.replaceWith(
oldPluginState.mathNodePos,
oldPluginState.mathNodePos +
(existingNode?.nodeSize || 1),
equationNode
)
tr.setSelection(
TextSelection.create(
tr.doc,
oldPluginState.mathNodePos + equationNode.nodeSize
)
)
return tracked(tr)
} else {
const tr = newState.tr.insert(
oldPluginState.from,
equationNode
)
tr.setSelection(
TextSelection.create(
tr.doc,
oldPluginState.from + equationNode.nodeSize
)
)
return tracked(tr)
}
}
/* ── cancel ── */
if (meta?.action === "cancel") {
if (!oldPluginState) {
return null
}
if (oldPluginState?.isEdit) {
// Leave the equation node unchanged; move cursor after it.
const node = newState.doc.nodeAt(oldPluginState.mathNodePos)
const tr = newState.tr.setSelection(
TextSelection.create(
newState.doc,
oldPluginState.mathNodePos + (node?.nodeSize || 1)
)
)
return tracked(tr)
} else {
// Restore the literal "$" at the original position.
const tr = newState.tr.insertText("$", oldPluginState.from)
tr.setSelection(
TextSelection.create(tr.doc, oldPluginState.from + 1)
)
return tracked(tr)
}
}
/* ── cancelDelete ── */
if (meta?.action === "cancelDelete") {
if (!oldPluginState) {
return null
}
if (oldPluginState?.isEdit) {
// Delete the equation node entirely.
const existingNode = newState.doc.nodeAt(
oldPluginState.mathNodePos
)
const tr = newState.tr.delete(
oldPluginState.mathNodePos,
oldPluginState.mathNodePos +
(existingNode?.nodeSize || 1)
)
tr.setSelection(
TextSelection.create(tr.doc, oldPluginState.mathNodePos)
)
return tracked(tr)
} else {
// New node: just deactivate — don't insert anything.
return newState.tr.setMeta(key, {action: "deactivate"})
}
}
/* ── cancelLeft ── */
if (meta?.action === "cancelLeft") {
if (!oldPluginState) {
return null
}
if (oldPluginState?.isEdit) {
const tr = newState.tr.setSelection(
TextSelection.create(
newState.doc,
oldPluginState.mathNodePos
)
)
return tracked(tr)
} else {
const tr = newState.tr.insertText(
"$",
oldPluginState.from
)
tr.setSelection(
TextSelection.create(tr.doc, oldPluginState.from)
)
return tracked(tr)
}
}
/* ── implicit commit when selection moves away ── */
if (oldPluginState?.active) {
if (
trs.some(tr => tr.selectionSet) &&
(newState.selection.from !== oldPluginState.from ||
newState.selection.to !== oldPluginState.from)
) {
const query = oldPluginState.query
const latex = query.startsWith("$") ? query.slice(1) : query
if (latex.trim()) {
const equationNode =
newState.schema.nodes.equation.create({
equation: latex
})
if (oldPluginState.isEdit) {
const existingNode = newState.doc.nodeAt(
oldPluginState.mathNodePos
)
return tracked(
newState.tr.replaceWith(
oldPluginState.mathNodePos,
oldPluginState.mathNodePos +
(existingNode?.nodeSize || 1),
equationNode
)
)
} else {
return tracked(
newState.tr.insert(
oldPluginState.from,
equationNode
)
)
}
} else {
return newState.tr.setMeta(key, {action: "deactivate"})
}
}
} else {
/* ── equation NodeSelection → open inline editor ── */
const selection = newState.selection
if (
selection instanceof NodeSelection &&
selection.node.type.name === "equation"
) {
const eqNode = selection.node
const from = selection.from
return newState.tr.setMeta(key, {
action: "activate",
from,
query: `$${eqNode.attrs.equation}`,
isEdit: true,
mathNodePos: from
})
}
/* ── "$" typed via paste/mobile (doc changed) ── */
const changedTr = trs.find(tr => tr.docChanged)
if (changedTr) {
const $pos = newState.selection.$head
if (canActivateInlineMath(newState, $pos, editor)) {
const beforeText = newState.doc.textBetween(
Math.max(0, $pos.pos - 3),
$pos.pos,
""
)
if (beforeText.endsWith("$")) {
return newState.tr
.delete($pos.pos - 1, $pos.pos)
.setMeta(key, {
action: "activate",
from: $pos.pos - 1,
query: "$",
isEdit: false,
mathNodePos: 0
})
}
}
}
}
return null
},
props: {
decorations(state: EditorState) {
const pluginState = key.getState(state) as
| InlineEditorState
| undefined
return pluginState?.decos || DecorationSet.empty
},
handleClickOn(
_view: EditorView,
_pos: number,
node: ProseMirrorNode,
nodePos: number,
_event: MouseEvent,
_direct: boolean
) {
if (node.type.name === "equation") {
const tr = _view.state.tr.setSelection(
TextSelection.create(
_view.state.doc,
nodePos + node.nodeSize
)
)
tr.setMeta(key, {
action: "activate",
from: nodePos,
query: `$${node.attrs.equation}`,
isEdit: true,
mathNodePos: nodePos
})
_view.dispatch(tr)
return true
}
return false
},
handleDOMEvents: {
mousedown(view: EditorView, event: MouseEvent) {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return false
}
if (event.button !== 0) {
return false
}
const target = event.target as HTMLElement | null
const closest = target?.closest(".equation")
if (!closest) {
return false
}
let pos = view.posAtDOM(closest, 0)
let node = view.state.doc.nodeAt(pos)
if (!node || node.type.name !== "equation") {
return false
}
const pluginState = key.getState(view.state) as
| InlineEditorState
| undefined
if (pluginState?.active) {
// Commit the current widget before opening a new one.
const commitTr = view.state.tr.setMeta(key, {
action: "commit"
})
view.dispatch(commitTr)
pos = commitTr.mapping.map(pos)
node = view.state.doc.nodeAt(pos)
if (!node || node.type.name !== "equation") {
return false
}
}
const tr = view.state.tr.setSelection(
TextSelection.create(
view.state.doc,
pos + node.nodeSize
)
)
tr.setMeta(key, {
action: "activate",
from: pos,
query: `$${node.attrs.equation}`,
isEdit: true,
mathNodePos: pos
})
view.dispatch(tr)
event.stopPropagation()
event.preventDefault()
return true
},
click(view: EditorView, event: MouseEvent) {
const pluginState = key.getState(view.state) as
| InlineEditorState
| undefined
if (pluginState?.active) {
const target = event.target as HTMLElement | null
if (target?.closest(".inline-math-widget")) {
// Click inside the widget: let the widget handle it.
return false
}
// Widget is active and user clicked elsewhere: the
// focusout handler will commit asynchronously; prevent
// the click from doing anything unexpected.
event.preventDefault()
event.stopPropagation()
return true
}
return false
}
},
handleKeyDown(view: EditorView, event: KeyboardEvent) {
const pluginState = key.getState(view.state) as
| InlineEditorState
| undefined
if (!pluginState?.active) {
/* ── "$" keypress → activate inline editor ── */
if (event.key === "$") {
const $pos = view.state.selection.$head
if (canActivateInlineMath(view.state, $pos, editor)) {
event.preventDefault()
view.dispatch(
view.state.tr.setMeta(key, {
action: "activate",
from: $pos.pos,
query: "$",
isEdit: false,
mathNodePos: 0
})
)
return true
}
}
/* ── ArrowRight into an equation node ── */
if (event.key === "ArrowRight") {
const sel = view.state.selection
if (sel.empty) {
const nodeAfter = sel.$head.nodeAfter
if (
nodeAfter &&
nodeAfter.type.name === "equation"
) {
event.preventDefault()
const from = sel.$head.pos
view.dispatch(
view.state.tr.setMeta(key, {
action: "activate",
from,
query: `$${nodeAfter.attrs.equation}`,
isEdit: true,
mathNodePos: from,
cursorAtStart: true
})
)
return true
}
}
}
/* ── ArrowLeft into an equation node ── */
if (event.key === "ArrowLeft") {
const sel = view.state.selection
if (sel.empty) {
const nodeBefore = sel.$head.nodeBefore
if (
nodeBefore &&
nodeBefore.type.name === "equation"
) {
event.preventDefault()
const from = sel.$head.pos - nodeBefore.nodeSize
view.dispatch(
view.state.tr.setMeta(key, {
action: "activate",
from,
query: `$${nodeBefore.attrs.equation}`,
isEdit: true,
mathNodePos: from,
cursorAtStart: false
})
)
return true
}
}
}
return false
}
// When the widget is active it traps its own events; nothing
// extra needs to be handled here.
return false
}
}
})
}
/* ────────────────────────────────────────────────────────── */
/* Public plugin factory */
/* ────────────────────────────────────────────────────────── */
export const inlineMathPlugin = (options: {editor: Editor}): Plugin => {
const editor = options.editor
const appConfig = editor.app as {
config?: {user?: {preferences?: {inline_math?: boolean}}}
}
const enabled =
appConfig.config?.user?.preferences?.inline_math === true &&
!READ_ONLY_ROLES.includes(editor.docInfo.access_rights as string) &&
!COMMENT_ONLY_ROLES.includes(editor.docInfo.access_rights as string)
return enabled ? inlineEditorPlugin(options) : dropupPlugin(options)
}