first commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
// elements which should contain only block elements
|
||||
const blockContainingElements = new Set([
|
||||
'DL',
|
||||
'FIELDSET',
|
||||
'FIGURE',
|
||||
'HEAD',
|
||||
'OL',
|
||||
'TABLE',
|
||||
'TBODY',
|
||||
'TFOOT',
|
||||
'THEAD',
|
||||
'TR',
|
||||
'UL',
|
||||
])
|
||||
|
||||
export const isBlockContainingElement = (node: Node): node is HTMLElement =>
|
||||
blockContainingElements.has(node.nodeName)
|
||||
|
||||
// elements which are block elements (as opposed to inline elements)
|
||||
const blockElements = new Set([
|
||||
'ADDRESS',
|
||||
'ARTICLE',
|
||||
'ASIDE',
|
||||
'BLOCKQUOTE',
|
||||
'BODY',
|
||||
'CANVAS',
|
||||
'DD',
|
||||
'DIV',
|
||||
'DL',
|
||||
'DT',
|
||||
'FIELDSET',
|
||||
'FIGCAPTION',
|
||||
'FIGURE',
|
||||
'FOOTER',
|
||||
'FORM',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'HEADER',
|
||||
'HGROUP',
|
||||
'HR',
|
||||
'LI',
|
||||
'MAIN',
|
||||
'NAV',
|
||||
'NOSCRIPT',
|
||||
'OL',
|
||||
'P',
|
||||
'PRE',
|
||||
'SECTION',
|
||||
'TABLE',
|
||||
'TBODY',
|
||||
'TD',
|
||||
'TFOOT',
|
||||
'TH',
|
||||
'THEAD',
|
||||
'TR',
|
||||
'UL',
|
||||
'VIDEO',
|
||||
])
|
||||
|
||||
export const isBlockElement = (node: Node): node is HTMLElement =>
|
||||
blockElements.has(node.nodeName)
|
||||
|
||||
const inlineElements = new Set([
|
||||
'A',
|
||||
'ABBR',
|
||||
'ACRONYM',
|
||||
'B',
|
||||
'BIG',
|
||||
'CITE',
|
||||
'DEL',
|
||||
'EM',
|
||||
'I',
|
||||
'INS',
|
||||
'SMALL',
|
||||
'SPAN',
|
||||
'STRONG',
|
||||
'SUB',
|
||||
'SUP',
|
||||
'TEXTAREA', // TODO
|
||||
'TIME',
|
||||
'TT',
|
||||
])
|
||||
|
||||
export const isInlineElement = (node: Node): node is HTMLElement =>
|
||||
inlineElements.has(node.nodeName)
|
||||
|
||||
const codeElements = new Set(['CODE', 'PRE'])
|
||||
|
||||
export const isCodeElement = (node: Node): node is HTMLElement =>
|
||||
codeElements.has(node.nodeName)
|
||||
|
||||
const keepEmptyBlockElements = new Set(['TD', 'TH', 'CANVAS', 'DT', 'DD', 'HR'])
|
||||
|
||||
export const shouldRemoveEmptyBlockElement = (
|
||||
node: Node
|
||||
): node is HTMLElement =>
|
||||
!keepEmptyBlockElements.has(node.nodeName) && !node.hasChildNodes()
|
||||
|
||||
export const isTextNode = (node: Node): node is Text =>
|
||||
node.nodeType === Node.TEXT_NODE
|
||||
|
||||
export const isElementNode = (node: Node): node is HTMLElement =>
|
||||
node.nodeType === Node.ELEMENT_NODE
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
SelectionRange,
|
||||
Transaction,
|
||||
} from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
/**
|
||||
* A transaction filter which modifies a transaction if it places the cursor in front of a list item marker,
|
||||
* to ensure that the cursor is positioned after the marker.
|
||||
*/
|
||||
export const listItemMarker = EditorState.transactionFilter.of(tr => {
|
||||
if (tr.selection) {
|
||||
let selection = tr.selection
|
||||
for (const [index, range] of tr.selection.ranges.entries()) {
|
||||
if (range.empty) {
|
||||
const node = syntaxTree(tr.state).resolveInner(range.anchor, 1)
|
||||
const pos = chooseTargetPosition(node, tr, range, index)
|
||||
if (pos !== null) {
|
||||
selection = selection.replaceRange(
|
||||
EditorSelection.cursor(
|
||||
pos,
|
||||
range.assoc,
|
||||
range.bidiLevel ?? undefined, // workaround for inconsistent types
|
||||
range.goalColumn
|
||||
),
|
||||
index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selection !== tr.selection) {
|
||||
return [tr, { selection }]
|
||||
}
|
||||
}
|
||||
return tr
|
||||
})
|
||||
|
||||
const chooseTargetPosition = (
|
||||
node: SyntaxNode,
|
||||
tr: Transaction,
|
||||
range: SelectionRange,
|
||||
index: number
|
||||
) => {
|
||||
let targetNode
|
||||
if (node.type.is('Item')) {
|
||||
targetNode = node
|
||||
} else if (node.type.is('ItemCtrlSeq')) {
|
||||
targetNode = node.parent
|
||||
} else if (
|
||||
node.type.is('Whitespace') &&
|
||||
node.nextSibling?.type.is('Command')
|
||||
) {
|
||||
targetNode = node.nextSibling?.firstChild?.firstChild
|
||||
}
|
||||
|
||||
if (!targetNode?.type.is('Item')) {
|
||||
return null
|
||||
}
|
||||
|
||||
// mouse click
|
||||
if (tr.isUserEvent('select.pointer')) {
|
||||
// jump to after the item
|
||||
return targetNode.to
|
||||
}
|
||||
|
||||
const previousHead = tr.startState.selection.ranges[index]?.head
|
||||
|
||||
// keyboard navigation
|
||||
if (range.head < previousHead) {
|
||||
// moving backwards: jump to end of the previous line
|
||||
return Math.max(tr.state.doc.lineAt(range.anchor).from - 1, 1)
|
||||
} else {
|
||||
// moving forwards: jump to after the item
|
||||
return targetNode.to
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import { EditorState, Range } from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { getUnstarredEnvironmentName } from '../../utils/tree-operations/environments'
|
||||
import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure'
|
||||
import { parseTheoremStyles } from '../../utils/tree-operations/theorems'
|
||||
import { Tree } from '@lezer/common'
|
||||
import { parseColorArguments } from '../../utils/tree-operations/colors'
|
||||
|
||||
/**
|
||||
* A view plugin that decorates ranges of text with Mark decorations.
|
||||
* Mark decorations add attributes to elements within a range.
|
||||
*/
|
||||
export const markDecorations = ViewPlugin.define(
|
||||
view => {
|
||||
const createDecorations = (
|
||||
state: EditorState,
|
||||
tree: Tree
|
||||
): DecorationSet => {
|
||||
const decorations: Range<Decoration>[] = []
|
||||
|
||||
const theoremStyles = parseTheoremStyles(state, tree)
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
tree?.iterate({
|
||||
from,
|
||||
to,
|
||||
enter(nodeRef) {
|
||||
if (
|
||||
nodeRef.type.is('KnownCommand') ||
|
||||
nodeRef.type.is('UnknownCommand')
|
||||
) {
|
||||
// decorate commands with a class, for optional styling
|
||||
const ctrlSeq =
|
||||
nodeRef.node.getChild('$CtrlSeq') ??
|
||||
nodeRef.node.firstChild?.getChild('$CtrlSeq')
|
||||
|
||||
if (ctrlSeq) {
|
||||
const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
|
||||
|
||||
// a special case for "label" as the whole command needs a space afterwards
|
||||
if (text === 'label') {
|
||||
// decorate the whole command
|
||||
const from = nodeRef.from
|
||||
const to = nodeRef.to
|
||||
if (to > from) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: `ol-cm-${text}`,
|
||||
inclusive: true,
|
||||
}).range(from, to)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// decorate the command content
|
||||
const from = ctrlSeq.to + 1
|
||||
const to = nodeRef.to - 1
|
||||
if (to > from) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: `ol-cm-command-${text}`,
|
||||
inclusive: true,
|
||||
}).range(from, to)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (nodeRef.type.is('SectioningCommand')) {
|
||||
// decorate section headings with a class, for styling
|
||||
const ctrlSeq = nodeRef.node.getChild('$CtrlSeq')
|
||||
if (ctrlSeq) {
|
||||
const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
|
||||
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: `ol-cm-heading ol-cm-command-${text}`,
|
||||
}).range(nodeRef.from, nodeRef.to)
|
||||
)
|
||||
}
|
||||
} else if (nodeRef.type.is('Caption') || nodeRef.type.is('Label')) {
|
||||
const type = nodeRef.type.is('Caption') ? 'caption' : 'label'
|
||||
// decorate caption and label lines with a class, for styling
|
||||
const argument = nodeRef.node.getChild('$Argument')
|
||||
|
||||
if (argument) {
|
||||
const lines = {
|
||||
start: state.doc.lineAt(nodeRef.from),
|
||||
end: state.doc.lineAt(nodeRef.to),
|
||||
}
|
||||
|
||||
for (
|
||||
let lineNumber = lines.start.number;
|
||||
lineNumber <= lines.end.number;
|
||||
lineNumber++
|
||||
) {
|
||||
const line = state.doc.line(lineNumber)
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: `ol-cm-${type}-line`,
|
||||
}).range(line.from)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (nodeRef.type.is('TextColorCommand')) {
|
||||
const result = parseColorArguments(state, nodeRef.node)
|
||||
|
||||
if (result) {
|
||||
const { color, from, to } = result
|
||||
|
||||
// decorate the content
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'ol-cm-textcolor',
|
||||
inclusive: true,
|
||||
attributes: {
|
||||
style: `color: ${color}`,
|
||||
},
|
||||
}).range(from, to)
|
||||
)
|
||||
}
|
||||
} else if (nodeRef.type.is('ColorBoxCommand')) {
|
||||
const result = parseColorArguments(state, nodeRef.node)
|
||||
|
||||
if (result) {
|
||||
const { color, from, to } = result
|
||||
|
||||
// decorate the content
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'ol-cm-colorbox',
|
||||
inclusive: true,
|
||||
attributes: {
|
||||
style: `background-color: ${color}`,
|
||||
},
|
||||
}).range(from, to)
|
||||
)
|
||||
}
|
||||
} else if (nodeRef.type.is('$Environment')) {
|
||||
const environmentName = getUnstarredEnvironmentName(
|
||||
nodeRef.node,
|
||||
state
|
||||
)
|
||||
|
||||
if (environmentName) {
|
||||
switch (environmentName) {
|
||||
case 'abstract':
|
||||
case 'figure':
|
||||
case 'table':
|
||||
case 'verbatim':
|
||||
case 'lstlisting':
|
||||
{
|
||||
const centered = Boolean(
|
||||
centeringNodeForEnvironment(nodeRef)
|
||||
)
|
||||
|
||||
const lines = {
|
||||
start: state.doc.lineAt(nodeRef.from),
|
||||
end: state.doc.lineAt(nodeRef.to),
|
||||
}
|
||||
|
||||
for (
|
||||
let lineNumber = lines.start.number;
|
||||
lineNumber <= lines.end.number;
|
||||
lineNumber++
|
||||
) {
|
||||
const line = state.doc.line(lineNumber)
|
||||
|
||||
const classNames = [
|
||||
`ol-cm-environment-${environmentName}`,
|
||||
'ol-cm-environment-line',
|
||||
]
|
||||
|
||||
if (centered) {
|
||||
classNames.push('ol-cm-environment-centered')
|
||||
}
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: classNames.join(' '),
|
||||
}).range(line.from)
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'quote':
|
||||
case 'quotation':
|
||||
case 'quoting':
|
||||
case 'displayquote':
|
||||
{
|
||||
const lines = {
|
||||
start: state.doc.lineAt(nodeRef.from),
|
||||
end: state.doc.lineAt(nodeRef.to),
|
||||
}
|
||||
|
||||
for (
|
||||
let lineNumber = lines.start.number;
|
||||
lineNumber <= lines.end.number;
|
||||
lineNumber++
|
||||
) {
|
||||
const line = state.doc.line(lineNumber)
|
||||
|
||||
const classNames = [
|
||||
`ol-cm-environment-${environmentName}`,
|
||||
'ol-cm-environment-quote-block',
|
||||
'ol-cm-environment-line',
|
||||
]
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: classNames.join(' '),
|
||||
}).range(line.from)
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
if (theoremStyles.has(environmentName)) {
|
||||
const theoremStyle = theoremStyles.get(environmentName)
|
||||
|
||||
if (theoremStyle) {
|
||||
const lines = {
|
||||
start: state.doc.lineAt(nodeRef.from),
|
||||
end: state.doc.lineAt(nodeRef.to),
|
||||
}
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: [
|
||||
`ol-cm-environment-theorem-${theoremStyle}`,
|
||||
'ol-cm-environment-first-line',
|
||||
].join(' '),
|
||||
}).range(lines.start.from)
|
||||
)
|
||||
|
||||
for (
|
||||
let lineNumber = lines.start.number + 1;
|
||||
lineNumber <= lines.end.number - 1;
|
||||
lineNumber++
|
||||
) {
|
||||
const line = state.doc.line(lineNumber)
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: [
|
||||
`ol-cm-environment-theorem-${theoremStyle}`,
|
||||
'ol-cm-environment-line',
|
||||
].join(' '),
|
||||
}).range(line.from)
|
||||
)
|
||||
}
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: [
|
||||
`ol-cm-environment-theorem-${theoremStyle}`,
|
||||
'ol-cm-environment-last-line',
|
||||
].join(' '),
|
||||
}).range(lines.start.from)
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true)
|
||||
}
|
||||
|
||||
let previousTree = syntaxTree(view.state)
|
||||
|
||||
return {
|
||||
decorations: createDecorations(view.state, previousTree),
|
||||
update(update: ViewUpdate) {
|
||||
const tree = syntaxTree(update.state)
|
||||
|
||||
// still parsing
|
||||
if (
|
||||
tree.type === previousTree.type &&
|
||||
tree.length < update.view.viewport.to
|
||||
) {
|
||||
this.decorations = this.decorations.map(update.changes)
|
||||
} else if (tree !== previousTree || update.viewportChanged) {
|
||||
// parsed or resized
|
||||
previousTree = tree
|
||||
// TODO: update the existing decorations for the changed range(s)?
|
||||
this.decorations = createDecorations(update.state, tree)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(value) {
|
||||
return value.decorations
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,920 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { Prec } from '@codemirror/state'
|
||||
import {
|
||||
insertPastedContent,
|
||||
pastedContent,
|
||||
storePastedContent,
|
||||
} from './pasted-content'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
isBlockContainingElement,
|
||||
isBlockElement,
|
||||
isElementNode,
|
||||
isInlineElement,
|
||||
isTextNode,
|
||||
shouldRemoveEmptyBlockElement,
|
||||
} from './html-elements'
|
||||
|
||||
export const pasteHtml = [
|
||||
Prec.highest(
|
||||
EditorView.domEventHandlers({
|
||||
paste(event, view) {
|
||||
const { clipboardData } = event
|
||||
|
||||
if (!clipboardData) {
|
||||
return false
|
||||
}
|
||||
|
||||
// only handle pasted HTML
|
||||
if (!clipboardData.types.includes('text/html')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// ignore text/html from VS Code
|
||||
if (
|
||||
clipboardData.types.includes('application/vnd.code.copymetadata') ||
|
||||
clipboardData.types.includes('vscode-editor-data')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const html = clipboardData.getData('text/html').trim()
|
||||
const text = clipboardData.getData('text/plain').trim()
|
||||
|
||||
if (html.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// convert the HTML to LaTeX
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const { documentElement } = parser.parseFromString(html, 'text/html')
|
||||
|
||||
// fall back to creating a figure when there's an image on the clipoard,
|
||||
// unless the HTML indicates that it came from an Office application
|
||||
// (which also puts an image on the clipboard)
|
||||
if (
|
||||
clipboardData.files.length > 0 &&
|
||||
!hasProgId(documentElement) &&
|
||||
!isOnlyTable(documentElement)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const bodyElement = documentElement.querySelector('body')
|
||||
// DOMParser should always create a body element, so this is mostly for TypeScript
|
||||
if (!bodyElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the only content is in a code block, use the plain text version
|
||||
if (onlyCode(bodyElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const latex = htmlToLaTeX(bodyElement)
|
||||
|
||||
// if there's no formatting, use the plain text version
|
||||
if (latex === text && clipboardData.files.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
view.dispatch(insertPastedContent(view, { latex, text }))
|
||||
view.dispatch(storePastedContent({ latex, text }, true))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
debugConsole.error(error)
|
||||
|
||||
// fall back to the default paste handler
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
),
|
||||
pastedContent,
|
||||
]
|
||||
|
||||
const removeUnwantedElements = (
|
||||
documentElement: HTMLElement,
|
||||
selector: string
|
||||
) => {
|
||||
for (const element of documentElement.querySelectorAll(selector)) {
|
||||
element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const findCodeContainingElement = (documentElement: HTMLElement) => {
|
||||
let result: HTMLElement | null
|
||||
|
||||
// a code element
|
||||
result = documentElement.querySelector<HTMLElement>('code')
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
|
||||
// a pre element with "monospace" somewhere in the font family
|
||||
result = documentElement.querySelector<HTMLPreElement>('pre')
|
||||
if (result?.style.fontFamily.includes('monospace')) {
|
||||
return result
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// return true if the text content of the first <code> element
|
||||
// is the same as the text content of the whole document element
|
||||
const onlyCode = (documentElement: HTMLElement) => {
|
||||
const codeElement = findCodeContainingElement(documentElement)
|
||||
|
||||
return (
|
||||
codeElement?.textContent?.trim() === documentElement.textContent?.trim()
|
||||
)
|
||||
}
|
||||
|
||||
const hasProgId = (documentElement: HTMLElement) => {
|
||||
const meta = documentElement.querySelector<HTMLMetaElement>(
|
||||
'meta[name="ProgId"]'
|
||||
)
|
||||
return meta && meta.content.trim().length > 0
|
||||
}
|
||||
|
||||
// detect a table (probably pasted from desktop Excel)
|
||||
const isOnlyTable = (documentElement: HTMLElement) => {
|
||||
const body = documentElement.querySelector<HTMLBodyElement>('body')
|
||||
|
||||
return (
|
||||
body &&
|
||||
body.childElementCount === 1 &&
|
||||
body.firstElementChild!.nodeName === 'TABLE'
|
||||
)
|
||||
}
|
||||
|
||||
const htmlToLaTeX = (bodyElement: HTMLElement) => {
|
||||
// remove style elements
|
||||
removeUnwantedElements(bodyElement, 'style')
|
||||
|
||||
let before: string | null = null
|
||||
let after: string | null = null
|
||||
|
||||
// repeat until the content stabilises
|
||||
do {
|
||||
before = bodyElement.textContent
|
||||
|
||||
// normalise whitespace in text
|
||||
normaliseWhitespace(bodyElement)
|
||||
|
||||
// replace unwanted whitespace in blocks
|
||||
processWhitespaceInBlocks(bodyElement)
|
||||
|
||||
after = bodyElement.textContent
|
||||
} while (before !== after)
|
||||
|
||||
// pre-process table elements
|
||||
processTables(bodyElement)
|
||||
|
||||
// pre-process lists
|
||||
processLists(bodyElement)
|
||||
|
||||
// protect special characters in non-LaTeX text nodes
|
||||
protectSpecialCharacters(bodyElement)
|
||||
|
||||
processMatchedElements(bodyElement)
|
||||
|
||||
const text = bodyElement.textContent
|
||||
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
text
|
||||
// remove zero-width spaces (e.g. those added by Powerpoint)
|
||||
.replaceAll('', '')
|
||||
// normalise multiple newlines
|
||||
.replaceAll(/\n{2,}/g, '\n\n')
|
||||
// only allow a single newline at the start and end
|
||||
.replaceAll(/(^\n+|\n+$)/g, '\n')
|
||||
// replace tab with 4 spaces (hard-coded indent unit)
|
||||
.replaceAll('\t', ' ')
|
||||
)
|
||||
}
|
||||
|
||||
const trimInlineElements = (
|
||||
element: HTMLElement,
|
||||
precedingSpace = true
|
||||
): boolean => {
|
||||
for (const node of element.childNodes) {
|
||||
if (isTextNode(node)) {
|
||||
let text = node.textContent!
|
||||
|
||||
if (precedingSpace) {
|
||||
text = text.replace(/^\s+/, '')
|
||||
}
|
||||
|
||||
if (text === '') {
|
||||
node.remove()
|
||||
} else {
|
||||
node.textContent = text
|
||||
precedingSpace = /\s$/.test(text)
|
||||
}
|
||||
} else if (isInlineElement(node)) {
|
||||
precedingSpace = trimInlineElements(node, precedingSpace)
|
||||
} else if (isBlockElement(node)) {
|
||||
precedingSpace = true // TODO
|
||||
} else {
|
||||
precedingSpace = false // TODO
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: trim whitespace at the end
|
||||
|
||||
return precedingSpace
|
||||
}
|
||||
|
||||
const processWhitespaceInBlocks = (documentElement: HTMLElement) => {
|
||||
trimInlineElements(documentElement)
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
documentElement,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
node =>
|
||||
isElementNode(node) && isElementContainingCode(node)
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT
|
||||
)
|
||||
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
// TODO: remove leading newline from pre, code and textarea?
|
||||
if (isBlockContainingElement(node)) {
|
||||
// remove all text nodes directly inside elements that should only contain blocks
|
||||
for (const childNode of node.childNodes) {
|
||||
if (isTextNode(childNode)) {
|
||||
childNode.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBlockElement(node)) {
|
||||
trimInlineElements(node)
|
||||
|
||||
if (shouldRemoveEmptyBlockElement(node)) {
|
||||
node.remove()
|
||||
// TODO: and parents?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normaliseWhitespace = (documentElement: HTMLElement) => {
|
||||
const walker = document.createTreeWalker(
|
||||
documentElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
node =>
|
||||
isElementNode(node) && isElementContainingCode(node)
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT
|
||||
)
|
||||
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const text = node.textContent
|
||||
if (text !== null) {
|
||||
if (/^\s+$/.test(text)) {
|
||||
// replace nodes containing only whitespace (including non-breaking space) with a single space
|
||||
node.textContent = ' '
|
||||
} else {
|
||||
// collapse contiguous whitespace (except for non-breaking space) to a single space
|
||||
node.textContent = text.replaceAll(/[\n\r\f\t \u2028\u2029]+/g, ' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: negative lookbehind once Safari supports it
|
||||
const specialCharacterRegExp = /(^|[^\\])([#$%&~_^\\{}])/g
|
||||
|
||||
const specialCharacterReplacer = (
|
||||
_match: string,
|
||||
prefix: string,
|
||||
char: string
|
||||
) => {
|
||||
if (char === '\\') {
|
||||
// convert `\` to `\textbackslash{}`, preserving subsequent whitespace
|
||||
char = 'textbackslash{}'
|
||||
}
|
||||
|
||||
return `${prefix}\\${char}`
|
||||
}
|
||||
|
||||
const isElementContainingCode = (element: HTMLElement) =>
|
||||
element.nodeName === 'CODE' ||
|
||||
(element.nodeName === 'PRE' && element.style.fontFamily.includes('monospace'))
|
||||
|
||||
const protectSpecialCharacters = (documentElement: HTMLElement) => {
|
||||
const walker = document.createTreeWalker(
|
||||
documentElement,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
node =>
|
||||
isElementNode(node) && isElementContainingCode(node)
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT
|
||||
)
|
||||
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (isTextNode(node)) {
|
||||
const text = node.textContent
|
||||
if (text) {
|
||||
// replace non-backslash-prefixed characters
|
||||
node.textContent = text.replaceAll(
|
||||
specialCharacterRegExp,
|
||||
specialCharacterReplacer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processMatchedElements = (documentElement: HTMLElement) => {
|
||||
for (const item of selectors) {
|
||||
for (const element of documentElement.querySelectorAll<any>(
|
||||
item.selector
|
||||
)) {
|
||||
if (!item.match || item.match(element)) {
|
||||
// start the markup
|
||||
if (item.start) {
|
||||
const start = document.createTextNode(item.start(element))
|
||||
if (item.inside) {
|
||||
element.prepend(start)
|
||||
} else {
|
||||
element.before(start)
|
||||
}
|
||||
}
|
||||
|
||||
// end the markup
|
||||
if (item.end) {
|
||||
const end = document.createTextNode(item.end(element))
|
||||
if (item.inside) {
|
||||
element.append(end)
|
||||
} else {
|
||||
element.after(end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matchingParents = (element: HTMLElement, selector: string) => {
|
||||
const matches = []
|
||||
|
||||
for (
|
||||
let ancestor = element.parentElement?.closest(selector);
|
||||
ancestor;
|
||||
ancestor = ancestor.parentElement?.closest(selector)
|
||||
) {
|
||||
matches.push(ancestor)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
const urlCharacterReplacements = new Map<string, string>([
|
||||
['\\', '\\\\'],
|
||||
['#', '\\#'],
|
||||
['%', '\\%'],
|
||||
['{', '%7B'],
|
||||
['}', '%7D'],
|
||||
])
|
||||
|
||||
const protectUrlCharacters = (url: string) => {
|
||||
// NOTE: add new characters to both this regex and urlCharacterReplacements
|
||||
return url.replaceAll(/[\\#%{}]/g, match => {
|
||||
const replacement = urlCharacterReplacements.get(match)
|
||||
if (!replacement) {
|
||||
throw new Error(`No replacement found for ${match}`)
|
||||
}
|
||||
return replacement
|
||||
})
|
||||
}
|
||||
|
||||
const processLists = (element: HTMLElement) => {
|
||||
for (const list of element.querySelectorAll('ol,ul')) {
|
||||
// if the list has only one item, replace the list with an element containing the contents of the item
|
||||
if (list.childElementCount === 1) {
|
||||
const div = document.createElement('div')
|
||||
div.append(...list.firstElementChild!.childNodes)
|
||||
list.before('\n', div, '\n')
|
||||
list.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processTables = (element: HTMLElement) => {
|
||||
for (const table of element.querySelectorAll('table')) {
|
||||
// create a wrapper element for the table and the caption
|
||||
const container = document.createElement('div')
|
||||
container.className = 'ol-table-wrap'
|
||||
table.after(container)
|
||||
|
||||
// move the caption (if it exists) into the container before the table
|
||||
const caption = table.querySelector('caption')
|
||||
if (caption) {
|
||||
container.append(caption)
|
||||
}
|
||||
|
||||
// move the table into the container
|
||||
container.append(table)
|
||||
|
||||
// add empty cells to account for rowspan
|
||||
for (const cell of table.querySelectorAll<HTMLTableCellElement>(
|
||||
'th[rowspan],td[rowspan]'
|
||||
)) {
|
||||
const rowspan = Number(cell.getAttribute('rowspan') || '1')
|
||||
const colspan = Number(cell.getAttribute('colspan') || '1')
|
||||
|
||||
let row: HTMLTableRowElement | null = cell.closest('tr')
|
||||
if (row) {
|
||||
let position = 0
|
||||
for (const child of row.cells) {
|
||||
if (child === cell) {
|
||||
break
|
||||
}
|
||||
position += Number(child.getAttribute('colspan') || '1')
|
||||
}
|
||||
for (let i = 1; i < rowspan; i++) {
|
||||
const nextElement: Element | null = row?.nextElementSibling
|
||||
if (!isTableRow(nextElement)) {
|
||||
break
|
||||
}
|
||||
row = nextElement
|
||||
|
||||
let targetCell: HTMLTableCellElement | undefined
|
||||
let targetPosition = 0
|
||||
for (const child of row.cells) {
|
||||
if (targetPosition === position) {
|
||||
targetCell = child
|
||||
break
|
||||
}
|
||||
targetPosition += Number(child.getAttribute('colspan') || '1')
|
||||
}
|
||||
|
||||
const fillerCells = Array.from({ length: colspan }, () =>
|
||||
document.createElement('td')
|
||||
)
|
||||
|
||||
if (targetCell) {
|
||||
targetCell.before(...fillerCells)
|
||||
} else {
|
||||
row.append(...fillerCells)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isTableRow = (element: Element | null): element is HTMLTableRowElement =>
|
||||
element?.nodeName === 'TR'
|
||||
|
||||
const cellAlignment = new Map([
|
||||
['left', 'l'],
|
||||
['center', 'c'],
|
||||
['right', 'r'],
|
||||
])
|
||||
|
||||
const tabular = (element: HTMLTableElement) => {
|
||||
const definitions: Array<{
|
||||
alignment: string
|
||||
borderLeft: boolean
|
||||
borderRight: boolean
|
||||
}> = []
|
||||
|
||||
const rows = element.querySelectorAll('tr')
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = [...row.childNodes].filter(
|
||||
element => element.nodeName === 'TD' || element.nodeName === 'TH'
|
||||
) as Array<HTMLTableCellElement>
|
||||
|
||||
let index = 0
|
||||
|
||||
for (const cell of cells) {
|
||||
// NOTE: reading the alignment and borders from the first cell definition in each column
|
||||
if (definitions[index] === undefined) {
|
||||
const { textAlign, borderLeftStyle, borderRightStyle } = cell.style
|
||||
|
||||
definitions[index] = {
|
||||
alignment: textAlign,
|
||||
borderLeft: visibleBorderStyle(borderLeftStyle),
|
||||
borderRight: visibleBorderStyle(borderRightStyle),
|
||||
}
|
||||
}
|
||||
index += Number(cell.getAttribute('colspan') ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index <= definitions.length; index++) {
|
||||
// fill in missing definitions
|
||||
const item = definitions[index] || {
|
||||
alignment: 'left',
|
||||
borderLeft: false,
|
||||
borderRight: false,
|
||||
}
|
||||
|
||||
// remove left border if previous column had a right border
|
||||
if (item.borderLeft && index > 0 && definitions[index - 1]?.borderRight) {
|
||||
item.borderLeft = false
|
||||
}
|
||||
}
|
||||
|
||||
return definitions
|
||||
.flatMap(definition => [
|
||||
definition.borderLeft ? '|' : '',
|
||||
cellAlignment.get(definition.alignment) ?? 'l',
|
||||
definition.borderRight ? '|' : '',
|
||||
])
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const listDepth = (element: HTMLElement): number =>
|
||||
Math.max(0, matchingParents(element, 'ul,ol').length)
|
||||
|
||||
const indentUnit = ' ' // TODO: replace hard-coded indent unit?
|
||||
|
||||
const listIndent = (element: HTMLElement | null): string =>
|
||||
element ? indentUnit.repeat(listDepth(element)) : ''
|
||||
|
||||
type ElementSelector<T extends string, E extends HTMLElement = HTMLElement> = {
|
||||
selector: T
|
||||
match?: (element: E) => boolean
|
||||
start?: (element: E) => string
|
||||
end?: (element: E) => string
|
||||
inside?: boolean
|
||||
}
|
||||
|
||||
const createSelector = <
|
||||
T extends string,
|
||||
E extends HTMLElement = T extends keyof HTMLElementTagNameMap
|
||||
? HTMLElementTagNameMap[T]
|
||||
: HTMLElement,
|
||||
>({
|
||||
selector,
|
||||
...elementSelector
|
||||
}: ElementSelector<T, E>) => ({
|
||||
selector,
|
||||
...elementSelector,
|
||||
})
|
||||
|
||||
const headings = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
|
||||
|
||||
const isHeading = (element: HTMLElement | null) => {
|
||||
return element && headings.includes(element.nodeName)
|
||||
}
|
||||
|
||||
const hasContent = (element: HTMLElement): boolean => {
|
||||
return Boolean(element.textContent && element.textContent.trim().length > 0)
|
||||
}
|
||||
|
||||
type BorderStyle =
|
||||
| 'borderTopStyle'
|
||||
| 'borderRightStyle'
|
||||
| 'borderBottomStyle'
|
||||
| 'borderLeftStyle'
|
||||
|
||||
const visibleBorderStyle = (style: CSSStyleDeclaration[BorderStyle]): boolean =>
|
||||
!!style && style !== 'none' && style !== 'hidden'
|
||||
|
||||
const rowHasBorderStyle = (
|
||||
element: HTMLTableRowElement,
|
||||
style: BorderStyle
|
||||
): boolean => {
|
||||
if (visibleBorderStyle(element.style[style])) {
|
||||
return true
|
||||
}
|
||||
|
||||
const cells = element.querySelectorAll<HTMLTableCellElement>('th,td')
|
||||
|
||||
return [...cells].every(cell => visibleBorderStyle(cell.style[style]))
|
||||
}
|
||||
|
||||
const isTableRowElement = (
|
||||
element: Element | null
|
||||
): element is HTMLTableRowElement => element?.nodeName === 'TR'
|
||||
|
||||
const nextRowHasBorderStyle = (
|
||||
element: HTMLTableRowElement,
|
||||
style: BorderStyle
|
||||
) => {
|
||||
const { nextElementSibling } = element
|
||||
return (
|
||||
isTableRowElement(nextElementSibling) &&
|
||||
rowHasBorderStyle(nextElementSibling, style)
|
||||
)
|
||||
}
|
||||
|
||||
const startMulticolumn = (element: HTMLTableCellElement): string => {
|
||||
const colspan = Number(element.getAttribute('colspan') || 1)
|
||||
const alignment = cellAlignment.get(element.style.textAlign) ?? 'l'
|
||||
return `\\multicolumn{${colspan}}{${alignment}}{`
|
||||
}
|
||||
|
||||
const startMultirow = (element: HTMLTableCellElement): string => {
|
||||
const rowspan = Number(element.getAttribute('rowspan') || 1)
|
||||
// NOTE: it would be useful to read cell width if specified, using `*` as a starting point
|
||||
return `\\multirow{${rowspan}}{*}{`
|
||||
}
|
||||
|
||||
const listPrefix = (element: HTMLOListElement | HTMLUListElement) => {
|
||||
if (isListOrListItemElement(element.parentElement)) {
|
||||
// within a list = newline
|
||||
return '\n'
|
||||
}
|
||||
// outside a list = double newline
|
||||
return '\n\n'
|
||||
}
|
||||
|
||||
const listSuffix = (element: HTMLOListElement | HTMLUListElement) => {
|
||||
if (listDepth(element) === 0) {
|
||||
// a top-level list => newline
|
||||
return '\n'
|
||||
} else {
|
||||
// a nested list => no extra newline
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const isListElement = (
|
||||
element: Element | null
|
||||
): element is HTMLOListElement | HTMLUListElement =>
|
||||
element !== null && listNodeNames.includes(element.nodeName)
|
||||
|
||||
const isListOrListItemElement = (
|
||||
element: Element | null
|
||||
): element is HTMLOListElement | HTMLUListElement =>
|
||||
element !== null && (isListElement(element) || element.nodeName === 'LI')
|
||||
|
||||
const listNodeNames = ['OL', 'UL']
|
||||
|
||||
const selectors = [
|
||||
createSelector({
|
||||
selector: 'b',
|
||||
match: element =>
|
||||
!element.style.fontWeight &&
|
||||
!isHeading(element.parentElement) &&
|
||||
hasContent(element),
|
||||
start: () => '\\textbf{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: '*',
|
||||
match: element =>
|
||||
(element.style.fontWeight === 'bold' ||
|
||||
parseInt(element.style.fontWeight) >= 700) &&
|
||||
hasContent(element),
|
||||
start: () => '\\textbf{',
|
||||
end: () => '}',
|
||||
inside: true,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'strong',
|
||||
match: element => !element.style.fontWeight && hasContent(element),
|
||||
start: () => '\\textbf{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'i',
|
||||
match: element => !element.style.fontStyle && hasContent(element),
|
||||
start: () => '\\textit{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: '*',
|
||||
match: element =>
|
||||
element.style.fontStyle === 'italic' && hasContent(element),
|
||||
start: () => '\\textit{',
|
||||
end: () => '}',
|
||||
inside: true,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'em',
|
||||
match: element => !element.style.fontStyle && hasContent(element),
|
||||
start: () => '\\textit{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'sup',
|
||||
match: element => !element.style.verticalAlign && hasContent(element),
|
||||
start: () => '\\textsuperscript{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'span',
|
||||
match: element =>
|
||||
element.style.verticalAlign === 'super' && hasContent(element),
|
||||
start: () => '\\textsuperscript{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'sub',
|
||||
match: element => !element.style.verticalAlign && hasContent(element),
|
||||
start: () => '\\textsubscript{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'span',
|
||||
match: element =>
|
||||
element.style.verticalAlign === 'sub' && hasContent(element),
|
||||
start: () => '\\textsubscript{',
|
||||
end: () => '}',
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'a',
|
||||
match: element => !!element.href && hasContent(element),
|
||||
start: (element: HTMLAnchorElement) => {
|
||||
const url = protectUrlCharacters(element.href)
|
||||
return `\\href{${url}}{`
|
||||
},
|
||||
end: () => `}`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'h1',
|
||||
match: element => !element.closest('table') && hasContent(element),
|
||||
start: () => `\n\n\\section{`,
|
||||
end: () => `}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'h2',
|
||||
match: element => !element.closest('table') && hasContent(element),
|
||||
start: () => `\n\n\\subsection{`,
|
||||
end: () => `}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'h3',
|
||||
match: element => !element.closest('table') && hasContent(element),
|
||||
start: () => `\n\n\\subsubsection{`,
|
||||
end: () => `}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'h4',
|
||||
match: element => !element.closest('table') && hasContent(element),
|
||||
start: () => `\n\n\\paragraph{`,
|
||||
end: () => `}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'h5',
|
||||
match: element => !element.closest('table') && hasContent(element),
|
||||
start: () => `\n\n\\subparagraph{`,
|
||||
end: () => `}\n\n`,
|
||||
}),
|
||||
// TODO: h6?
|
||||
createSelector({
|
||||
selector: 'br',
|
||||
match: element => !element.closest('table'),
|
||||
start: () => `\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'code',
|
||||
match: element =>
|
||||
element.parentElement?.nodeName !== 'PRE' && hasContent(element),
|
||||
start: () => `\\verb|`,
|
||||
end: () => `|`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'pre > code',
|
||||
match: element => hasContent(element),
|
||||
start: () => `\n\n\\begin{verbatim}\n`,
|
||||
end: () => `\n\\end{verbatim}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'pre',
|
||||
match: element =>
|
||||
element.style.fontFamily.includes('monospace') &&
|
||||
element.firstElementChild?.nodeName !== 'CODE' &&
|
||||
hasContent(element),
|
||||
start: () => `\n\n\\begin{verbatim}\n`,
|
||||
end: () => `\n\\end{verbatim}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: '.ol-table-wrap',
|
||||
start: () => `\n\n\\begin{table}\n\\centering\n`,
|
||||
end: () => `\n\\end{table}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'table',
|
||||
start: element => `\n\\begin{tabular}{${tabular(element)}}`,
|
||||
end: () => `\\end{tabular}\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'thead',
|
||||
start: () => `\n`,
|
||||
end: () => `\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'tfoot',
|
||||
start: () => `\n`,
|
||||
end: () => `\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'tbody',
|
||||
start: () => `\n`,
|
||||
end: () => `\n`,
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'tr',
|
||||
start: element => {
|
||||
const borderTop = rowHasBorderStyle(element, 'borderTopStyle')
|
||||
return borderTop ? '\\hline\n' : ''
|
||||
},
|
||||
end: element => {
|
||||
const borderBottom = rowHasBorderStyle(element, 'borderBottomStyle')
|
||||
return borderBottom && !nextRowHasBorderStyle(element, 'borderTopStyle')
|
||||
? '\n\\hline\n'
|
||||
: '\n'
|
||||
},
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'tr > td, tr > th',
|
||||
start: (element: HTMLTableCellElement) => {
|
||||
let output = ''
|
||||
const colspan = element.getAttribute('colspan')
|
||||
if (colspan && Number(colspan) > 1) {
|
||||
output += startMulticolumn(element)
|
||||
}
|
||||
// NOTE: multirow is nested inside multicolumn
|
||||
const rowspan = element.getAttribute('rowspan')
|
||||
if (rowspan && Number(rowspan) > 1) {
|
||||
output += startMultirow(element)
|
||||
}
|
||||
return output
|
||||
},
|
||||
end: element => {
|
||||
let output = ''
|
||||
// NOTE: multirow is nested inside multicolumn
|
||||
const rowspan = element.getAttribute('rowspan')
|
||||
if (rowspan && Number(rowspan) > 1) {
|
||||
output += '}'
|
||||
}
|
||||
const colspan = element.getAttribute('colspan')
|
||||
if (colspan && Number(colspan) > 1) {
|
||||
output += '}'
|
||||
}
|
||||
const row = element.parentElement as HTMLTableRowElement
|
||||
const isLastChild = row.cells.item(row.cells.length - 1) === element
|
||||
return output + (isLastChild ? ' \\\\' : ' & ')
|
||||
},
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'caption',
|
||||
start: () => `\n\n\\caption{`,
|
||||
end: () => `}\n\n`,
|
||||
}),
|
||||
createSelector({
|
||||
// selector: 'ul:has(> li:nth-child(2))', // only select lists with at least 2 items (once Firefox supports :has())
|
||||
selector: 'ul',
|
||||
start: element => {
|
||||
return `${listPrefix(element)}${listIndent(element)}\\begin{itemize}`
|
||||
},
|
||||
end: element => {
|
||||
return `\n${listIndent(element)}\\end{itemize}${listSuffix(element)}`
|
||||
},
|
||||
}),
|
||||
createSelector({
|
||||
// selector: 'ol:has(> li:nth-child(2))', // only select lists with at least 2 items (once Firefox supports :has())
|
||||
selector: 'ol',
|
||||
start: element => {
|
||||
return `${listPrefix(element)}${listIndent(element)}\\begin{enumerate}`
|
||||
},
|
||||
end: element => {
|
||||
return `\n${listIndent(element)}\\end{enumerate}${listSuffix(element)}`
|
||||
},
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'li',
|
||||
start: element => {
|
||||
return `\n${listIndent(element.parentElement)}${indentUnit}\\item `
|
||||
},
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'p',
|
||||
match: element => {
|
||||
// must have content
|
||||
if (!hasContent(element)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// inside lists and tables, must precede another paragraph
|
||||
if (element.closest('li') || element.closest('table')) {
|
||||
return element.nextElementSibling?.nodeName === 'P'
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
end: () => '\n\n',
|
||||
}),
|
||||
createSelector({
|
||||
selector: 'blockquote',
|
||||
start: () => `\n\n\\begin{quote}\n`,
|
||||
end: () => `\n\\end{quote}\n\n`,
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
Range,
|
||||
StateEffect,
|
||||
StateField,
|
||||
} from '@codemirror/state'
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
|
||||
import { undo } from '@codemirror/commands'
|
||||
import { ancestorNodeOfType } from '../../utils/tree-operations/ancestors'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { PastedContentMenu } from '../../components/paste-html/pasted-content-menu'
|
||||
import { SplitTestProvider } from '../../../../shared/context/split-test-context'
|
||||
|
||||
export type PastedContent = { latex: string; text: string }
|
||||
|
||||
const pastedContentEffect = StateEffect.define<{
|
||||
content: PastedContent
|
||||
formatted: boolean
|
||||
}>()
|
||||
|
||||
export const insertPastedContent = (
|
||||
view: EditorView,
|
||||
{ latex, text }: PastedContent
|
||||
) =>
|
||||
view.state.changeByRange(range => {
|
||||
// avoid pasting formatted content into a math container
|
||||
if (ancestorNodeOfType(view.state, range.anchor, '$MathContainer')) {
|
||||
return {
|
||||
range: EditorSelection.cursor(range.from + text.length),
|
||||
changes: { from: range.from, to: range.to, insert: text },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
range: EditorSelection.cursor(range.from + latex.length),
|
||||
changes: { from: range.from, to: range.to, insert: latex },
|
||||
}
|
||||
})
|
||||
|
||||
export const storePastedContent = (
|
||||
content: PastedContent,
|
||||
formatted: boolean
|
||||
) => ({
|
||||
effects: pastedContentEffect.of({ content, formatted }),
|
||||
})
|
||||
|
||||
const pastedContentTheme = EditorView.baseTheme({
|
||||
'.ol-cm-pasted-content-menu-toggle': {
|
||||
background: 'none',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgb(125, 125, 125)',
|
||||
color: 'inherit',
|
||||
margin: '0 4px',
|
||||
opacity: '0.7',
|
||||
'&:hover': {
|
||||
opacity: '1',
|
||||
},
|
||||
'& .material-symbols': {
|
||||
verticalAlign: 'text-bottom',
|
||||
},
|
||||
},
|
||||
'.ol-cm-pasted-content-menu-popover': {
|
||||
backgroundColor: '#fff',
|
||||
maxWidth: 'unset',
|
||||
'& .popover-content': {
|
||||
padding: 0,
|
||||
},
|
||||
'& .popover-body': {
|
||||
color: 'inherit',
|
||||
padding: 0,
|
||||
},
|
||||
'& .popover-arrow::after': {
|
||||
borderBottomColor: '#fff',
|
||||
},
|
||||
},
|
||||
'&dark .ol-cm-pasted-content-menu-popover': {
|
||||
background: 'rgba(0, 0, 0)',
|
||||
},
|
||||
'.ol-cm-pasted-content-menu': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
},
|
||||
'.ol-cm-pasted-content-menu-item': {
|
||||
color: 'inherit',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
padding: '8px 16px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
gap: '12px',
|
||||
'&[aria-disabled="true"]': {
|
||||
color: 'rgba(125, 125, 125, 0.5)',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.2)',
|
||||
},
|
||||
},
|
||||
'.ol-cm-pasted-content-menu-item-label': {
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
},
|
||||
'.ol-cm-pasted-content-menu-item-shortcut': {
|
||||
textAlign: 'right',
|
||||
},
|
||||
})
|
||||
|
||||
export const pastedContent = StateField.define<{
|
||||
content: PastedContent
|
||||
formatted: boolean
|
||||
selection: EditorSelection
|
||||
} | null>({
|
||||
create() {
|
||||
return null
|
||||
},
|
||||
update(value, tr) {
|
||||
if (tr.docChanged) {
|
||||
// TODO: exclude remote changes (if they don't intersect with changed ranges)?
|
||||
value = null
|
||||
} else {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(pastedContentEffect)) {
|
||||
value = {
|
||||
...effect.value,
|
||||
selection: tr.state.selection,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
provide(field) {
|
||||
return [
|
||||
EditorView.decorations.compute([field], state => {
|
||||
const value = state.field(field)
|
||||
|
||||
if (!value) {
|
||||
return Decoration.none
|
||||
}
|
||||
|
||||
const decorations: Range<Decoration>[] = []
|
||||
|
||||
const { content, selection, formatted } = value
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new PastedContentMenuWidget(content, formatted),
|
||||
side: 1,
|
||||
}).range(selection.main.to)
|
||||
)
|
||||
|
||||
return Decoration.set(decorations, true)
|
||||
}),
|
||||
pastedContentTheme,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
class PastedContentMenuWidget extends WidgetType {
|
||||
constructor(
|
||||
private pastedContent: PastedContent,
|
||||
private formatted: boolean
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const element = document.createElement('span')
|
||||
ReactDOM.render(
|
||||
<SplitTestProvider>
|
||||
<PastedContentMenu
|
||||
insertPastedContent={this.insertPastedContent}
|
||||
view={view}
|
||||
formatted={this.formatted}
|
||||
pastedContent={this.pastedContent}
|
||||
/>
|
||||
</SplitTestProvider>,
|
||||
element
|
||||
)
|
||||
return element
|
||||
}
|
||||
|
||||
insertPastedContent(
|
||||
view: EditorView,
|
||||
pastedContent: PastedContent,
|
||||
formatted: boolean
|
||||
) {
|
||||
undo(view)
|
||||
view.dispatch(
|
||||
insertPastedContent(view, {
|
||||
latex: formatted ? pastedContent.latex : pastedContent.text,
|
||||
text: pastedContent.text,
|
||||
})
|
||||
)
|
||||
view.dispatch(storePastedContent(pastedContent, formatted))
|
||||
view.focus()
|
||||
}
|
||||
|
||||
eq(widget: PastedContentMenuWidget) {
|
||||
return (
|
||||
widget.pastedContent === this.pastedContent &&
|
||||
widget.formatted === this.formatted
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
SelectionRange,
|
||||
StateField,
|
||||
} from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { Tree } from '@lezer/common'
|
||||
import {
|
||||
ancestorOfNodeWithType,
|
||||
descendantsOfNodeWithType,
|
||||
} from '../../utils/tree-operations/ancestors'
|
||||
import { getMousedownSelection, selectionIntersects } from './selection'
|
||||
import { DecorationSet } from '@codemirror/view'
|
||||
|
||||
/**
|
||||
* A custom extension that updates the selection in a transaction if the mouse pointer was used
|
||||
* to position a cursor at the start or end of an argument (the cursor is placed inside the brace),
|
||||
* or to drag a range across the whole range of an argument (the selection is placed inside the braces),
|
||||
* when the selection was not already inside the command.
|
||||
*/
|
||||
export const selectDecoratedArgument = (
|
||||
field: StateField<{ decorations: DecorationSet }>
|
||||
) =>
|
||||
EditorState.transactionFilter.of(tr => {
|
||||
if (tr.selection && tr.isUserEvent('select.pointer')) {
|
||||
const tree = syntaxTree(tr.state)
|
||||
let selection = tr.selection
|
||||
const mousedownSelection = getMousedownSelection(tr.state)
|
||||
let replaced = false
|
||||
const rangeSet = tr.state.field(field, false)?.decorations
|
||||
for (const [index, range] of selection.ranges.entries()) {
|
||||
if (rangeSet) {
|
||||
let isAtomicRange = false
|
||||
rangeSet.between(range.anchor, range.anchor, (_from, to) => {
|
||||
if (to > range.anchor) {
|
||||
isAtomicRange = true
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (isAtomicRange === false) {
|
||||
// skip since decoration is not covering the selection
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const replacementRange =
|
||||
selectArgument(tree, range, mousedownSelection, 1) ||
|
||||
selectArgument(tree, range, mousedownSelection, -1)
|
||||
if (replacementRange) {
|
||||
selection = selection.replaceRange(replacementRange, index)
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if (replaced) {
|
||||
return [tr, { selection }]
|
||||
}
|
||||
}
|
||||
|
||||
return tr
|
||||
})
|
||||
|
||||
const selectArgument = (
|
||||
tree: Tree,
|
||||
range: SelectionRange,
|
||||
mousedownSelection: EditorSelection | undefined,
|
||||
side: -1 | 1
|
||||
): SelectionRange | undefined => {
|
||||
const anchor = tree.resolveInner(range.anchor, side)
|
||||
|
||||
const ancestorCommand = ancestorOfNodeWithType(anchor, '$Command')
|
||||
if (!ancestorCommand) {
|
||||
return
|
||||
}
|
||||
|
||||
const mousedownSelectionInside =
|
||||
mousedownSelection !== undefined &&
|
||||
selectionIntersects(mousedownSelection, ancestorCommand)
|
||||
if (mousedownSelectionInside) {
|
||||
return
|
||||
}
|
||||
|
||||
const [inner] = descendantsOfNodeWithType(ancestorCommand, '$TextArgument')
|
||||
if (!inner) {
|
||||
return
|
||||
}
|
||||
|
||||
if (side === 1) {
|
||||
if (
|
||||
range.anchor === inner.from + 1 ||
|
||||
range.anchor === ancestorCommand.from
|
||||
) {
|
||||
if (range.empty) {
|
||||
// selecting at the start
|
||||
return EditorSelection.cursor(inner.from + 1)
|
||||
} else if (Math.abs(range.head - inner.to) < 2) {
|
||||
// selecting from the start to the end
|
||||
return EditorSelection.range(inner.from + 1, inner.to - 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (range.anchor === inner.to - 1 || range.anchor === ancestorCommand.to) {
|
||||
if (range.empty) {
|
||||
// selecting at the end
|
||||
return EditorSelection.cursor(inner.to - 1)
|
||||
} else if (Math.abs(range.head - ancestorCommand.from) < 2) {
|
||||
// selecting from the end to the start
|
||||
return EditorSelection.range(inner.to - 1, inner.from + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
StateEffect,
|
||||
Line,
|
||||
Text,
|
||||
StateField,
|
||||
EditorState,
|
||||
} from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { hasEffect, updateHasEffect } from '../../utils/effects'
|
||||
|
||||
export const selectionIntersects = (
|
||||
selection: EditorSelection,
|
||||
extents: { from: number; to: number }
|
||||
) =>
|
||||
selection.ranges.some(
|
||||
range =>
|
||||
// Case 1: from is inside node
|
||||
(extents.from <= range.from && extents.to >= range.from) ||
|
||||
// Case 2: to is inside node
|
||||
(extents.from <= range.to && extents.to >= range.to)
|
||||
)
|
||||
|
||||
export const placeSelectionInsideBlock = (
|
||||
view: EditorView,
|
||||
event: MouseEvent
|
||||
) => {
|
||||
const line = view.lineBlockAtHeight(event.pageY - view.documentTop)
|
||||
|
||||
const selectionRange = EditorSelection.cursor(line.to)
|
||||
const selection = event.ctrlKey
|
||||
? view.state.selection.addRange(selectionRange)
|
||||
: selectionRange
|
||||
|
||||
return { selection, effects: EditorView.scrollIntoView(line.to) }
|
||||
}
|
||||
|
||||
export const extendBackwardsOverEmptyLines = (
|
||||
doc: Text,
|
||||
line: Line,
|
||||
limit: number = Number.POSITIVE_INFINITY
|
||||
) => {
|
||||
let { number, from } = line
|
||||
for (
|
||||
let lineNumber = number - 1;
|
||||
lineNumber > 0 && number - lineNumber <= limit;
|
||||
lineNumber--
|
||||
) {
|
||||
const line = doc.line(lineNumber)
|
||||
if (line.text.trim().length > 0) {
|
||||
break
|
||||
}
|
||||
from = line.from
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
export const extendForwardsOverEmptyLines = (
|
||||
doc: Text,
|
||||
line: Line,
|
||||
limit: number = Number.POSITIVE_INFINITY
|
||||
) => {
|
||||
let { number, to } = line
|
||||
for (
|
||||
let lineNumber = number + 1;
|
||||
lineNumber <= doc.lines && lineNumber - number <= limit;
|
||||
lineNumber++
|
||||
) {
|
||||
const line = doc.line(lineNumber)
|
||||
if (line.text.trim().length > 0) {
|
||||
break
|
||||
}
|
||||
to = line.to
|
||||
}
|
||||
|
||||
return to
|
||||
}
|
||||
|
||||
export const mouseDownEffect = StateEffect.define<boolean>()
|
||||
export const hasMouseDownEffect = hasEffect(mouseDownEffect)
|
||||
export const updateHasMouseDownEffect = updateHasEffect(mouseDownEffect)
|
||||
|
||||
/**
|
||||
* A listener for mousedown and mouseup events, dispatching an event
|
||||
* to record the current mousedown status, which is stored in a state field.
|
||||
*/
|
||||
const mouseDownListener = EditorView.domEventHandlers({
|
||||
mousedown: (event, view) => {
|
||||
// not wrapped in a timeout, so update listeners know that the mouse is down before they process the selection
|
||||
view.dispatch({
|
||||
effects: mouseDownEffect.of(true),
|
||||
})
|
||||
},
|
||||
mouseup: (event, view) => {
|
||||
// wrap in a timeout, so update listeners receive this effect after the new selection has finished being handled
|
||||
window.setTimeout(() => {
|
||||
view.dispatch({
|
||||
effects: mouseDownEffect.of(false),
|
||||
})
|
||||
})
|
||||
},
|
||||
contextmenu: (event: MouseEvent, view) => {
|
||||
// treat a `contextmenu` event as a `mouseup` event, which isn't fired
|
||||
window.setTimeout(() => {
|
||||
view.dispatch({
|
||||
effects: mouseDownEffect.of(false),
|
||||
})
|
||||
})
|
||||
},
|
||||
drop: (event: MouseEvent, view) => {
|
||||
// treat a `drop` event as a `mouseup` event, which isn't fired
|
||||
window.setTimeout(() => {
|
||||
view.dispatch({
|
||||
effects: mouseDownEffect.of(false),
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const mousedownSelectionState = StateField.define<EditorSelection | undefined>({
|
||||
create() {
|
||||
return undefined
|
||||
},
|
||||
update(value, tr) {
|
||||
if (value && tr.docChanged) {
|
||||
value = value.map(tr.changes)
|
||||
}
|
||||
|
||||
for (const effect of tr.effects) {
|
||||
// store the previous selection on mousedown
|
||||
if (effect.is(mouseDownEffect)) {
|
||||
value = effect.value ? tr.startState.selection : undefined
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
export const getMousedownSelection = (state: EditorState) =>
|
||||
state.field(mousedownSelectionState)
|
||||
|
||||
export const mousedown = [mouseDownListener, mousedownSelectionState]
|
||||
@@ -0,0 +1,124 @@
|
||||
import { DecorationSet, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import {
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
RangeSet,
|
||||
StateField,
|
||||
} from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { collapsePreambleEffect, Preamble } from './visual-widgets/preamble'
|
||||
/**
|
||||
* A view plugin that moves the cursor from the start of the preamble into the document body when the doc is opened.
|
||||
*/
|
||||
export const skipPreambleWithCursor = (
|
||||
field: StateField<{ preamble: Preamble; decorations: DecorationSet }>
|
||||
) =>
|
||||
ViewPlugin.define((view: EditorView) => {
|
||||
let checkedOnce = false
|
||||
|
||||
const escapeFromAtomicRanges = (
|
||||
selection: EditorSelection,
|
||||
force = false
|
||||
) => {
|
||||
const originalSelection = selection
|
||||
|
||||
const atomicRangeSets = view.state
|
||||
.facet(EditorView.atomicRanges)
|
||||
.map(item => item(view))
|
||||
|
||||
for (const [index, range] of selection.ranges.entries()) {
|
||||
const anchor = skipAtomicRanges(
|
||||
view.state,
|
||||
atomicRangeSets,
|
||||
range.anchor
|
||||
)
|
||||
const head = skipAtomicRanges(view.state, atomicRangeSets, range.head)
|
||||
|
||||
if (anchor !== range.anchor || head !== range.head) {
|
||||
selection = selection.replaceRange(
|
||||
EditorSelection.range(anchor, head),
|
||||
index
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (force || selection !== originalSelection) {
|
||||
// TODO: needs to happen after cursor position is restored?
|
||||
window.setTimeout(() => {
|
||||
view.dispatch({
|
||||
selection,
|
||||
scrollIntoView: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const escapeFromPreamble = () => {
|
||||
const preamble = view.state.field(field, false)?.preamble
|
||||
if (preamble) {
|
||||
escapeFromAtomicRanges(
|
||||
EditorSelection.create([EditorSelection.cursor(preamble.to + 1)]),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
update(update) {
|
||||
if (checkedOnce) {
|
||||
if (
|
||||
update.transactions.some(tr =>
|
||||
tr.effects.some(effect => effect.is(collapsePreambleEffect))
|
||||
)
|
||||
) {
|
||||
escapeFromPreamble()
|
||||
}
|
||||
} else {
|
||||
const { state } = update
|
||||
|
||||
if (syntaxTree(state).length === state.doc.length) {
|
||||
checkedOnce = true
|
||||
|
||||
// Only move the cursor if we're at the default position (0). Otherwise
|
||||
// switching back and forth between source/RT while editing the preamble
|
||||
// would be annoying.
|
||||
if (
|
||||
state.selection.eq(
|
||||
EditorSelection.create([EditorSelection.cursor(0)])
|
||||
)
|
||||
) {
|
||||
escapeFromPreamble()
|
||||
} else {
|
||||
escapeFromAtomicRanges(state.selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const skipAtomicRanges = (
|
||||
state: EditorState,
|
||||
rangeSets: RangeSet<any>[],
|
||||
pos: number
|
||||
) => {
|
||||
let oldPos
|
||||
do {
|
||||
oldPos = pos
|
||||
|
||||
for (const rangeSet of rangeSets) {
|
||||
rangeSet.between(pos, pos, (_from, to) => {
|
||||
if (to > pos) {
|
||||
pos = to
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// move from the end of a line to the start of the next line
|
||||
if (pos !== oldPos && state.doc.lineAt(pos).to === pos) {
|
||||
pos++
|
||||
}
|
||||
} while (pos !== oldPos)
|
||||
|
||||
return Math.min(pos, state.doc.length)
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
export const tableGeneratorTheme = EditorView.baseTheme({
|
||||
'&dark .table-generator': {
|
||||
'--table-generator-active-border-color': '#ccc',
|
||||
'--table-generator-coming-soon-background-color': '#41464f',
|
||||
'--table-generator-coming-soon-color': '#fff',
|
||||
'--table-generator-divider-color': 'rgba(125,125,125,0.3)',
|
||||
'--table-generator-dropdown-divider-color': 'rgba(125,125,125,0.3)',
|
||||
'--table-generator-focus-border-color': '#5d7498',
|
||||
'--table-generator-inactive-border-color': '#888',
|
||||
'--table-generator-selected-background-color': '#ffffff2a',
|
||||
'--table-generator-selector-background-color': '#777',
|
||||
'--table-generator-selector-hover-color': '#3265b2',
|
||||
'--table-generator-toolbar-background': '#2c3645',
|
||||
'--table-generator-toolbar-button-active-background':
|
||||
'rgba(125, 125, 125, 0.4)',
|
||||
'--table-generator-toolbar-button-color': '#fff',
|
||||
'--table-generator-toolbar-button-hover-background':
|
||||
'rgba(125, 125, 125, 0.2)',
|
||||
'--table-generator-toolbar-dropdown-border-color': 'rgba(125,125,125,0.3)',
|
||||
'--table-generator-toolbar-dropdown-disabled-background':
|
||||
'rgba(125,125,125,0.3)',
|
||||
'--table-generator-toolbar-dropdown-disabled-color': '#999',
|
||||
'--table-generator-toolbar-dropdown-active-background': 'var(--green-10)',
|
||||
'--table-generator-toolbar-dropdown-active-color': 'var(--green-70)',
|
||||
'--table-generator-toolbar-dropdown-active-hover-background':
|
||||
'var(--green-10)',
|
||||
'--table-generator-toolbar-dropdown-active-active-background':
|
||||
'var(--green-20)',
|
||||
'--table-generator-toolbar-shadow-color': '#1e253029',
|
||||
'--table-generator-error-background': '#2c3645',
|
||||
'--table-generator-error-color': '#fff',
|
||||
'--table-generator-error-border-color': '#677283',
|
||||
'--table-generator-column-size-indicator-background': 'var(--neutral-80)',
|
||||
'--table-generator-column-size-indicator-hover-background':
|
||||
'var(--neutral-70)',
|
||||
'--table-generator-column-size-indicator-color': 'white',
|
||||
'--table-generator-column-size-indicator-hover-color': 'white',
|
||||
},
|
||||
|
||||
'&light .table-generator': {
|
||||
'--table-generator-active-border-color': '#666',
|
||||
'--table-generator-coming-soon-background-color': 'var(--neutral-10)',
|
||||
'--table-generator-coming-soon-color': 'var(--neutral-70)',
|
||||
'--table-generator-divider-color': 'var(--neutral-20)',
|
||||
'--table-generator-dropdown-divider-color': 'var(--neutral-20)',
|
||||
'--table-generator-focus-border-color': '#97b6e5',
|
||||
'--table-generator-inactive-border-color': '#dedede',
|
||||
'--table-generator-selected-background-color': 'var(--blue-10)',
|
||||
'--table-generator-selector-background-color': 'var(--neutral-30)',
|
||||
'--table-generator-selector-hover-color': '#3265b2',
|
||||
'--table-generator-toolbar-background': '#fff',
|
||||
'--table-generator-toolbar-button-active-background':
|
||||
'rgba(47, 58, 76, 0.16)',
|
||||
'--table-generator-toolbar-button-color': 'var(--neutral-70)',
|
||||
'--table-generator-toolbar-button-hover-background':
|
||||
'rgba(47, 58, 76, 0.08)',
|
||||
'--table-generator-toolbar-dropdown-border-color': 'var(--neutral-60)',
|
||||
'--table-generator-toolbar-dropdown-disabled-background': '#f2f2f2',
|
||||
'--table-generator-toolbar-dropdown-disabled-color': 'var(--neutral-40)',
|
||||
'--table-generator-toolbar-dropdown-active-background': 'var(--green-10)',
|
||||
'--table-generator-toolbar-dropdown-active-color': 'var(--green-70)',
|
||||
'--table-generator-toolbar-dropdown-active-hover-background':
|
||||
'var(--green-10)',
|
||||
'--table-generator-toolbar-dropdown-active-active-background':
|
||||
'var(--green-20)',
|
||||
'--table-generator-toolbar-shadow-color': '#1e253029',
|
||||
'--table-generator-error-background': '#F1F4F9',
|
||||
'--table-generator-error-color': 'black',
|
||||
'--table-generator-error-border-color': '#C3D0E3',
|
||||
'--table-generator-column-size-indicator-background': '#E7E9EE',
|
||||
'--table-generator-column-size-indicator-hover-background': '#D7DADF',
|
||||
'--table-generator-column-size-indicator-color': 'black',
|
||||
'--table-generator-column-size-indicator-hover-color': 'black',
|
||||
},
|
||||
|
||||
'.table-generator': {
|
||||
position: 'relative',
|
||||
'--table-generator-inactive-border-width': '1px',
|
||||
'--table-generator-active-border-width': '1px',
|
||||
'--table-generator-selector-handle-buffer': '12px',
|
||||
'--table-generator-focus-border-width': '2px',
|
||||
'--table-generator-focus-negative-border-width': '-2px',
|
||||
},
|
||||
|
||||
'.table-generator-cell.selected': {
|
||||
'background-color': 'var(--table-generator-selected-background-color)',
|
||||
},
|
||||
|
||||
'.table-generator-cell:focus-visible': {
|
||||
outline: '2px dotted var(--table-generator-focus-border-color)',
|
||||
},
|
||||
|
||||
'.table-generator-cell': {
|
||||
border:
|
||||
'var(--table-generator-inactive-border-width) dashed var(--table-generator-inactive-border-color)',
|
||||
'min-width': '40px',
|
||||
height: '30px',
|
||||
'&.selection-edge-top': {
|
||||
'--shadow-top':
|
||||
'0 var(--table-generator-focus-negative-border-width) 0 var(--table-generator-focus-border-color)',
|
||||
},
|
||||
'&.selection-edge-bottom': {
|
||||
'--shadow-bottom':
|
||||
'0 var(--table-generator-focus-border-width) 0 var(--table-generator-focus-border-color)',
|
||||
},
|
||||
'&.selection-edge-left': {
|
||||
'--shadow-left':
|
||||
'var(--table-generator-focus-negative-border-width) 0 0 var(--table-generator-focus-border-color)',
|
||||
},
|
||||
'&.selection-edge-right': {
|
||||
'--shadow-right':
|
||||
'var(--table-generator-focus-border-width) 0 0 var(--table-generator-focus-border-color)',
|
||||
},
|
||||
'box-shadow':
|
||||
'var(--shadow-top, 0 0 0 transparent), var(--shadow-bottom, 0 0 0 transparent), var(--shadow-left, 0 0 0 transparent), var(--shadow-right, 0 0 0 transparent)',
|
||||
'&.table-generator-cell-border-left': {
|
||||
'border-left-style': 'solid',
|
||||
'border-left-color': 'var(--table-generator-active-border-color)',
|
||||
'border-left-width': 'var(--table-generator-active-border-width)',
|
||||
},
|
||||
|
||||
'&.table-generator-cell-border-right': {
|
||||
'border-right-style': 'solid',
|
||||
'border-right-color': 'var(--table-generator-active-border-color)',
|
||||
'border-right-width': 'var(--table-generator-active-border-width)',
|
||||
},
|
||||
|
||||
'&.table-generator-row-border-top': {
|
||||
'border-top-style': 'solid',
|
||||
'border-top-color': 'var(--table-generator-active-border-color)',
|
||||
'border-top-width': 'var(--table-generator-active-border-width)',
|
||||
},
|
||||
|
||||
'&.table-generator-row-border-bottom': {
|
||||
'border-bottom-style': 'solid',
|
||||
'border-bottom-color': 'var(--table-generator-active-border-color)',
|
||||
'border-bottom-width': 'var(--table-generator-active-border-width)',
|
||||
},
|
||||
'& .table-generator-cell-render': {
|
||||
'overflow-x': 'auto',
|
||||
'overflow-y': 'hidden',
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-table': {
|
||||
'table-layout': 'fixed',
|
||||
width: '95%',
|
||||
'max-width': '95%',
|
||||
margin: '0 auto',
|
||||
cursor: 'default',
|
||||
|
||||
'& td': {
|
||||
'&:not(.editing)': {
|
||||
padding: '0 0.25em',
|
||||
},
|
||||
'vertical-align': 'top',
|
||||
|
||||
'&.alignment-left': {
|
||||
'text-align': 'left',
|
||||
},
|
||||
'&.alignment-right': {
|
||||
'text-align': 'right',
|
||||
},
|
||||
'&.alignment-center': {
|
||||
'text-align': 'center',
|
||||
},
|
||||
'&.alignment-paragraph': {
|
||||
'text-align': 'justify',
|
||||
},
|
||||
},
|
||||
|
||||
'& .table-generator-selector-cell': {
|
||||
padding: '0',
|
||||
border: 'none !important',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
|
||||
'&.row-selector': {
|
||||
width: 'calc(var(--table-generator-selector-handle-buffer) + 8px)',
|
||||
|
||||
'&::after': {
|
||||
width: '4px',
|
||||
bottom: '4px',
|
||||
height: 'calc(100% - 8px)',
|
||||
},
|
||||
},
|
||||
|
||||
'&.column-selector': {
|
||||
height: 'calc(var(--table-generator-selector-handle-buffer) + 8px)',
|
||||
|
||||
'&::after': {
|
||||
width: 'calc(100% - 8px)',
|
||||
height: '4px',
|
||||
right: '4px',
|
||||
},
|
||||
},
|
||||
|
||||
'&::after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
right: '8px',
|
||||
width: 'calc(100% - 8px)',
|
||||
height: 'calc(100% - 8px)',
|
||||
'background-color': 'var(--table-generator-selector-background-color)',
|
||||
'border-radius': '4px',
|
||||
},
|
||||
|
||||
'&:hover::after': {
|
||||
'background-color': 'var(--table-generator-selector-hover-color)',
|
||||
},
|
||||
|
||||
'&.fully-selected::after': {
|
||||
'background-color': 'var(--table-generator-selector-hover-color)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-floating-toolbar': {
|
||||
position: 'absolute',
|
||||
transform: 'translateY(-100%)',
|
||||
left: '0',
|
||||
right: '0',
|
||||
margin: '0 auto',
|
||||
// z-index of cursor layer is 150
|
||||
'z-index': '152',
|
||||
'border-radius': '4px',
|
||||
width: 'max-content',
|
||||
'justify-content': 'start',
|
||||
maxWidth: '100%',
|
||||
'background-color': 'var(--table-generator-toolbar-background)',
|
||||
'box-shadow': '0px 2px 4px 0px var(--table-generator-toolbar-shadow-color)',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
rowGap: '8px',
|
||||
'&.table-generator-toolbar-floating-custom-sizes': {
|
||||
top: '-8px',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-toolbar-button': {
|
||||
display: 'inline-flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'center',
|
||||
margin: '0',
|
||||
'background-color': 'transparent',
|
||||
border: 'none',
|
||||
'border-radius': '4px',
|
||||
'line-height': '1',
|
||||
overflow: 'hidden',
|
||||
color: 'var(--table-generator-toolbar-button-color)',
|
||||
'text-align': 'center',
|
||||
padding: '4px',
|
||||
|
||||
'&:not(first-child)': {
|
||||
'margin-left': '4px',
|
||||
},
|
||||
'&:not(:last-child)': {
|
||||
'margin-right': '4px',
|
||||
},
|
||||
|
||||
'& > span': {
|
||||
'font-size': '24px',
|
||||
},
|
||||
|
||||
'&:hover, &:focus': {
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-button-hover-background)',
|
||||
},
|
||||
|
||||
'&:active, &.active': {
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-button-active-background)',
|
||||
},
|
||||
|
||||
'&:hover, &:focus, &:active, &.active': {
|
||||
'box-shadow': 'none',
|
||||
},
|
||||
|
||||
'&[aria-disabled="true"]': {
|
||||
'&:hover, &:focus, &:active, &.active': {
|
||||
'background-color': 'transparent',
|
||||
},
|
||||
opacity: '0.2',
|
||||
},
|
||||
},
|
||||
|
||||
'.toolbar-beta-badge': {
|
||||
padding: '0 4px 2px 12px',
|
||||
},
|
||||
|
||||
'.table-generator-button-group': {
|
||||
display: 'inline-flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'center',
|
||||
'line-height': '1',
|
||||
overflow: 'hidden',
|
||||
'&:not(:last-child)': {
|
||||
'border-right': '1px solid var(--table-generator-divider-color)',
|
||||
'padding-right': '8px',
|
||||
'margin-right': '8px',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-button-menu-popover': {
|
||||
'background-color': 'var(--table-generator-toolbar-background) !important',
|
||||
'& .popover-content, & .popover-body': {
|
||||
padding: '4px',
|
||||
},
|
||||
'& .list-group': {
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
},
|
||||
'& > .arrow, & > .popover-arrow': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-cell-input': {
|
||||
'background-color': 'transparent',
|
||||
width: '100%',
|
||||
'text-align': 'inherit',
|
||||
height: '1.5em',
|
||||
'min-height': '100%',
|
||||
border: '1px solid var(--table-generator-toolbar-shadow-color)',
|
||||
padding: '0 0.25em',
|
||||
resize: 'none',
|
||||
'box-sizing': 'border-box',
|
||||
overflow: 'hidden',
|
||||
'&:focus, &:focus-visible': {
|
||||
outline: '2px solid var(--table-generator-focus-border-color)',
|
||||
'outline-offset': '-2px',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-border-options-coming-soon': {
|
||||
display: 'flex',
|
||||
margin: '4px',
|
||||
'font-size': '12px',
|
||||
background: 'var(--table-generator-coming-soon-background-color)',
|
||||
color: 'var(--table-generator-coming-soon-color)',
|
||||
padding: '8px',
|
||||
gap: '6px',
|
||||
'align-items': 'flex-start',
|
||||
'max-width': '240px',
|
||||
'font-family': 'var(--bs-body-font-family)',
|
||||
|
||||
'& .info-icon': {
|
||||
flex: ' 0 0 24px',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-toolbar-dropdown-toggle': {
|
||||
border: '1px solid var(--table-generator-toolbar-dropdown-border-color)',
|
||||
'box-shadow': 'none',
|
||||
background: 'transparent',
|
||||
'white-space': 'nowrap',
|
||||
color: 'var(--table-generator-toolbar-button-color)',
|
||||
'border-radius': '4px',
|
||||
padding: '6px 8px',
|
||||
gap: '8px',
|
||||
'min-width': '120px',
|
||||
'font-size': '14px',
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'space-between',
|
||||
'font-family': 'var(--bs-body-font-family)',
|
||||
height: '36px',
|
||||
|
||||
'&:not(:first-child)': {
|
||||
'margin-left': '8px',
|
||||
},
|
||||
|
||||
'&[aria-disabled="true"]': {
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-dropdown-disabled-background)',
|
||||
color: 'var(--table-generator-toolbar-dropdown-disabled-color)',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-toolbar-dropdown-popover': {
|
||||
'max-width': '300px',
|
||||
background: 'var(--table-generator-toolbar-background) !important',
|
||||
|
||||
'& .popover-content, & .popover-body': {
|
||||
padding: '0',
|
||||
},
|
||||
|
||||
'& > .arrow, & > .popover-arrow': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-toolbar-dropdown-menu': {
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
'min-width': '200px',
|
||||
padding: '4px',
|
||||
|
||||
'& > button': {
|
||||
border: 'none',
|
||||
'box-shadow': 'none',
|
||||
background: 'transparent',
|
||||
'white-space': 'nowrap',
|
||||
color: 'var(--table-generator-toolbar-button-color)',
|
||||
'border-radius': '4px',
|
||||
'font-size': '14px',
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'flex-start',
|
||||
'column-gap': '8px',
|
||||
'align-self': 'stretch',
|
||||
padding: '12px 8px',
|
||||
'font-family': 'var(--bs-body-font-family)',
|
||||
|
||||
'& .table-generator-button-label': {
|
||||
'align-self': 'stretch',
|
||||
flex: '1 0 auto',
|
||||
'text-align': 'left',
|
||||
},
|
||||
|
||||
'&.ol-cm-toolbar-dropdown-option-active': {
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-dropdown-active-background)',
|
||||
color: 'var(--table-generator-toolbar-dropdown-active-color)',
|
||||
},
|
||||
|
||||
'&:hover, &:focus': {
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-button-hover-background)',
|
||||
},
|
||||
|
||||
'&:active, &.active': {
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-button-active-background)',
|
||||
},
|
||||
|
||||
'&.ol-cm-toolbar-dropdown-option-active:hover, &.ol-cm-toolbar-dropdown-option-active:focus':
|
||||
{
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-dropdown-active-hover-background)',
|
||||
},
|
||||
|
||||
'&.ol-cm-toolbar-dropdown-option-active:active, &.ol-cm-toolbar-dropdown-option-active.active':
|
||||
{
|
||||
'background-color':
|
||||
'var(--table-generator-toolbar-dropdown-active-active-background)',
|
||||
},
|
||||
|
||||
'&:hover, &:focus, &:active, &.active': {
|
||||
'box-shadow': 'none',
|
||||
},
|
||||
|
||||
'&[aria-disabled="true"]': {
|
||||
'&:hover, &:focus, &:active, &.active': {
|
||||
'background-color': 'transparent',
|
||||
},
|
||||
color: 'var(--table-generator-toolbar-dropdown-disabled-color)',
|
||||
},
|
||||
},
|
||||
|
||||
'& > hr': {
|
||||
background: 'var(--table-generator-dropdown-divider-color)',
|
||||
margin: '2px 8px',
|
||||
display: 'block',
|
||||
'box-sizing': 'content-box',
|
||||
border: '0',
|
||||
height: '1px',
|
||||
},
|
||||
|
||||
'& .ol-cm-toolbar-dropdown-option-content': {
|
||||
textAlign: 'left',
|
||||
flexGrow: '1',
|
||||
},
|
||||
},
|
||||
|
||||
'.ol-cm-environment-table.table-generator-error-container, .ol-cm-environment-table.ol-cm-tabular':
|
||||
{
|
||||
background: 'rgba(125, 125, 125, 0.05)',
|
||||
'font-family': 'var(--bs-body-font-family)',
|
||||
},
|
||||
|
||||
'.table-generator-filler-row': {
|
||||
border: 'none !important',
|
||||
'& td': {
|
||||
'min-width': '40px',
|
||||
},
|
||||
},
|
||||
|
||||
'.table-generator-column-indicator-button': {
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 4px 2px 4px',
|
||||
background: 'var(--table-generator-column-size-indicator-background)',
|
||||
margin: 0,
|
||||
border: 'none',
|
||||
fontFamily: 'Lato, sans-serif',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
fontWeight: 400,
|
||||
display: 'flex',
|
||||
maxWidth: '100%',
|
||||
color: 'var(--table-generator-column-size-indicator-color)',
|
||||
|
||||
'&:hover': {
|
||||
background:
|
||||
'var(--table-generator-column-size-indicator-hover-background)',
|
||||
color: 'var(--table-generator-column-size-indicator-hover-color)',
|
||||
},
|
||||
|
||||
'& .table-generator-column-indicator-icon': {
|
||||
fontSize: '16px',
|
||||
lineHeight: '16px',
|
||||
},
|
||||
|
||||
'& .table-generator-column-indicator-label': {
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
},
|
||||
'.table-generator-column-widths-row': {
|
||||
height: '20px',
|
||||
'& td': {
|
||||
lineHeight: '1',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { IndentContext, indentString } from '@codemirror/language'
|
||||
|
||||
export const createListItem = (state: EditorState, pos: number) => {
|
||||
const cx = new IndentContext(state)
|
||||
const columns = cx.lineIndent(pos)
|
||||
const indent = indentString(state, columns)
|
||||
return `${indent}\\item `
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
export const selectNode = (view: EditorView, node: SyntaxNode) => {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.single(node.from + 1, node.to - 1),
|
||||
scrollIntoView: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { COMMAND_SUBSTITUTIONS } from '../visual-widgets/character'
|
||||
|
||||
type Markup = {
|
||||
elementType: keyof HTMLElementTagNameMap
|
||||
className?: string
|
||||
}
|
||||
|
||||
const textFormattingMarkupMap = new Map<string, Markup>([
|
||||
[
|
||||
'TextBoldCommand', // \\textbf
|
||||
{ elementType: 'b' },
|
||||
],
|
||||
[
|
||||
'TextItalicCommand', // \\textit
|
||||
{ elementType: 'i' },
|
||||
],
|
||||
[
|
||||
'TextSmallCapsCommand', // \\textsc
|
||||
{ elementType: 'span', className: 'ol-cm-command-textsc' },
|
||||
],
|
||||
[
|
||||
'TextTeletypeCommand', // \\texttt
|
||||
{ elementType: 'span', className: 'ol-cm-command-texttt' },
|
||||
],
|
||||
[
|
||||
'TextSuperscriptCommand', // \\textsuperscript
|
||||
{ elementType: 'sup' },
|
||||
],
|
||||
[
|
||||
'TextSubscriptCommand', // \\textsubscript
|
||||
{ elementType: 'sub' },
|
||||
],
|
||||
[
|
||||
'EmphasisCommand', // \\emph
|
||||
{ elementType: 'em' },
|
||||
],
|
||||
[
|
||||
'UnderlineCommand', // \\underline
|
||||
{ elementType: 'span', className: 'ol-cm-command-underline' },
|
||||
],
|
||||
])
|
||||
|
||||
const markupMap = new Map<string, Markup>([
|
||||
['\\and', { elementType: 'span', className: 'ol-cm-command-and' }],
|
||||
])
|
||||
|
||||
/**
|
||||
* Does a small amount of typesetting of LaTeX content into a DOM element.
|
||||
* Does **not** typeset math, you **must manually** invoke MathJax after this
|
||||
* function if you wish to typeset math content.
|
||||
* @param node The syntax node containing the text to be typeset
|
||||
* @param element The DOM element to typeset into
|
||||
* @param getText The editor state where `node` is from or a custom function
|
||||
*/
|
||||
export function typesetNodeIntoElement(
|
||||
node: SyntaxNode,
|
||||
element: HTMLElement,
|
||||
getText: EditorState | ((from: number, to: number) => string)
|
||||
) {
|
||||
if (getText instanceof EditorState) {
|
||||
getText = getText.sliceDoc.bind(getText)
|
||||
}
|
||||
|
||||
// If we're a TextArgument node, we should skip the braces
|
||||
const argument = node.getChild('LongArg')
|
||||
if (argument) {
|
||||
node = argument
|
||||
}
|
||||
|
||||
const ancestorStack = [element]
|
||||
|
||||
const ancestor = () => ancestorStack[ancestorStack.length - 1]
|
||||
const popAncestor = () => ancestorStack.pop()!
|
||||
const pushAncestor = (element: HTMLElement) => ancestorStack.push(element)
|
||||
|
||||
let from = node.from
|
||||
|
||||
const addMarkup = (markup: Markup, childNode: SyntaxNode) => {
|
||||
const element = document.createElement(markup.elementType)
|
||||
if (markup.className) {
|
||||
element.classList.add(markup.className)
|
||||
}
|
||||
pushAncestor(element)
|
||||
from = chooseFrom(childNode)
|
||||
}
|
||||
|
||||
node.cursor().iterate(
|
||||
function enter(childNodeRef) {
|
||||
const childNode = childNodeRef.node
|
||||
|
||||
if (from < childNode.from) {
|
||||
ancestor().append(
|
||||
document.createTextNode(getText(from, childNode.from))
|
||||
)
|
||||
from = childNode.from
|
||||
}
|
||||
|
||||
// commands defined in the grammar
|
||||
const markup = textFormattingMarkupMap.get(childNode.type.name)
|
||||
if (markup) {
|
||||
addMarkup(markup, childNode)
|
||||
return
|
||||
}
|
||||
|
||||
// commands not defined in the grammar
|
||||
const commandName = unknownCommandName(childNode, getText)
|
||||
if (commandName) {
|
||||
const markup = markupMap.get(commandName)
|
||||
if (markup) {
|
||||
addMarkup(markup, childNode)
|
||||
return
|
||||
}
|
||||
|
||||
if (['\\corref', '\\fnref', '\\thanks'].includes(commandName)) {
|
||||
// ignoring these commands
|
||||
from = childNode.to
|
||||
return false
|
||||
}
|
||||
|
||||
const symbol = COMMAND_SUBSTITUTIONS.get(commandName)
|
||||
if (symbol) {
|
||||
ancestor().append(document.createTextNode(symbol))
|
||||
from = childNode.to
|
||||
return false
|
||||
}
|
||||
} else if (childNode.type.is('LineBreak')) {
|
||||
ancestor().append(document.createElement('br'))
|
||||
from = childNode.to
|
||||
}
|
||||
},
|
||||
function leave(childNodeRef) {
|
||||
const childNode = childNodeRef.node
|
||||
|
||||
if (shouldHandleLeave(childNode, getText)) {
|
||||
const typeSetElement = popAncestor()
|
||||
ancestor().appendChild(typeSetElement)
|
||||
const textArgument = childNode.getChild('TextArgument')
|
||||
const endBrace = textArgument?.getChild('CloseBrace')
|
||||
if (endBrace) {
|
||||
from = endBrace.to
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (from < node.to) {
|
||||
ancestor().append(document.createTextNode(getText(from, node.to)))
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
const chooseFrom = (node: SyntaxNode) =>
|
||||
node.getChild('TextArgument')?.getChild('LongArg')?.from ?? node.to
|
||||
|
||||
const shouldHandleLeave = (
|
||||
node: SyntaxNode,
|
||||
getText: (from: number, to: number) => string
|
||||
) => {
|
||||
if (textFormattingMarkupMap.has(node.type.name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const commandName = unknownCommandName(node, getText)
|
||||
return commandName && markupMap.has(commandName)
|
||||
}
|
||||
|
||||
const unknownCommandName = (
|
||||
node: SyntaxNode,
|
||||
getText: (from: number, to: number) => string
|
||||
): string | undefined => {
|
||||
if (node.type.is('UnknownCommand')) {
|
||||
const commandNameNode = node.getChild('$CtrlSeq')
|
||||
if (commandNameNode) {
|
||||
return getText(commandNameNode.from, commandNameNode.to).trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { keymap } from '@codemirror/view'
|
||||
import {
|
||||
ChangeSpec,
|
||||
EditorSelection,
|
||||
Prec,
|
||||
SelectionRange,
|
||||
} from '@codemirror/state'
|
||||
import { ancestorNodeOfType } from '../../utils/tree-query'
|
||||
import {
|
||||
cursorIsAtStartOfListItem,
|
||||
indentDecrease,
|
||||
indentIncrease,
|
||||
} from '../toolbar/commands'
|
||||
import { createListItem } from '@/features/source-editor/extensions/visual/utils/list-item'
|
||||
import { getListType } from '../../utils/tree-operations/lists'
|
||||
|
||||
/**
|
||||
* A keymap which provides behaviours for the visual editor,
|
||||
* including lists and text formatting.
|
||||
*/
|
||||
export const visualKeymap = Prec.highest(
|
||||
keymap.of([
|
||||
// create a new list item with the same indentation
|
||||
{
|
||||
key: 'Enter',
|
||||
run: view => {
|
||||
const { state } = view
|
||||
|
||||
let handled = false
|
||||
|
||||
const changes = state.changeByRange(range => {
|
||||
if (range.empty) {
|
||||
const { from } = range
|
||||
const listNode = ancestorNodeOfType(state, from, 'ListEnvironment')
|
||||
if (listNode) {
|
||||
const line = state.doc.lineAt(range.from)
|
||||
const endLine = state.doc.lineAt(listNode.to)
|
||||
|
||||
if (line.number === endLine.number - 1) {
|
||||
// last item line
|
||||
if (/^\\item(\[])?$/.test(line.text.trim())) {
|
||||
// no content on this line
|
||||
|
||||
// outside the end of the current list
|
||||
const pos = listNode.to + 1
|
||||
|
||||
// delete the current line
|
||||
const deleteCurrentLine = {
|
||||
from: line.from,
|
||||
to: line.to + 1,
|
||||
insert: '',
|
||||
}
|
||||
|
||||
const changes: ChangeSpec[] = [deleteCurrentLine]
|
||||
|
||||
// the new cursor position
|
||||
let range: SelectionRange
|
||||
|
||||
// if this is a nested list, insert a new empty list item after this list
|
||||
if (
|
||||
listNode.parent?.parent?.parent?.parent?.type.is(
|
||||
'ListEnvironment'
|
||||
)
|
||||
) {
|
||||
const newListItem = createListItem(state, pos)
|
||||
|
||||
changes.push({
|
||||
from: pos,
|
||||
insert: newListItem + '\n',
|
||||
})
|
||||
|
||||
// place the cursor at the end of the new list item
|
||||
range = EditorSelection.cursor(pos + newListItem.length)
|
||||
} else {
|
||||
// place the cursor outside the end of the current list
|
||||
range = EditorSelection.cursor(pos)
|
||||
}
|
||||
|
||||
handled = true
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: range.map(state.changes(deleteCurrentLine)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle a list item that isn't at the end of a list
|
||||
let insert = '\n' + createListItem(state, from)
|
||||
|
||||
const countWhitespaceAfterPosition = (pos: number) => {
|
||||
const line = state.doc.lineAt(pos)
|
||||
const followingText = state.sliceDoc(pos, line.to)
|
||||
const matches = followingText.match(/^(\s+)/)
|
||||
return matches ? matches[1].length : 0
|
||||
}
|
||||
|
||||
let pos: number
|
||||
|
||||
if (getListType(state, listNode) === 'description') {
|
||||
insert = insert.replace(/\\item $/, '\\item[] ')
|
||||
// position the cursor inside the square brackets
|
||||
pos = from + insert.length - 2
|
||||
} else {
|
||||
// move the cursor past any whitespace on the new line
|
||||
pos = from + insert.length + countWhitespaceAfterPosition(from)
|
||||
}
|
||||
|
||||
handled = true
|
||||
|
||||
return {
|
||||
changes: { from, insert },
|
||||
range: EditorSelection.cursor(pos, -1),
|
||||
}
|
||||
}
|
||||
|
||||
const sectioningNode = ancestorNodeOfType(
|
||||
state,
|
||||
from,
|
||||
'SectioningCommand'
|
||||
)
|
||||
if (sectioningNode) {
|
||||
// jump out of a section heading to the start of the next line
|
||||
const nextLineNumber = state.doc.lineAt(from).number + 1
|
||||
if (nextLineNumber <= state.doc.lines) {
|
||||
const line = state.doc.line(nextLineNumber)
|
||||
handled = true
|
||||
return {
|
||||
range: EditorSelection.cursor(line.from),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { range }
|
||||
})
|
||||
|
||||
if (handled) {
|
||||
view.dispatch(changes, {
|
||||
scrollIntoView: true,
|
||||
userEvent: 'input',
|
||||
})
|
||||
}
|
||||
|
||||
return handled
|
||||
},
|
||||
},
|
||||
// Increase list indent
|
||||
{
|
||||
key: 'Mod-]',
|
||||
preventDefault: true,
|
||||
run: indentIncrease,
|
||||
},
|
||||
// Decrease list indent
|
||||
{
|
||||
key: 'Mod-[',
|
||||
preventDefault: true,
|
||||
run: indentDecrease,
|
||||
},
|
||||
// Increase list indent
|
||||
{
|
||||
key: 'Tab',
|
||||
preventDefault: true,
|
||||
run: view =>
|
||||
cursorIsAtStartOfListItem(view.state) && indentIncrease(view),
|
||||
},
|
||||
// Decrease list indent
|
||||
{
|
||||
key: 'Shift-Tab',
|
||||
preventDefault: true,
|
||||
run: indentDecrease,
|
||||
},
|
||||
])
|
||||
)
|
||||
@@ -0,0 +1,492 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { Annotation, Compartment, Extension, Facet } from '@codemirror/state'
|
||||
|
||||
/**
|
||||
* A syntax highlighter for content types that are only styled in the visual editor.
|
||||
*/
|
||||
export const visualHighlightStyle = syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: tags.link, class: 'ol-cm-link-text' },
|
||||
{ tag: tags.url, class: 'ol-cm-url' },
|
||||
{ tag: tags.typeName, class: 'ol-cm-monospace' },
|
||||
{ tag: tags.attributeValue, class: 'ol-cm-monospace' },
|
||||
{ tag: tags.keyword, class: 'ol-cm-monospace' },
|
||||
{ tag: tags.string, class: 'ol-cm-monospace' },
|
||||
{ tag: tags.punctuation, class: 'ol-cm-punctuation' },
|
||||
{ tag: tags.literal, class: 'ol-cm-monospace' },
|
||||
{ tag: tags.strong, class: 'ol-cm-strong' },
|
||||
{
|
||||
tag: tags.monospace,
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
lineHeight: 1,
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
const mainVisualTheme = EditorView.theme({
|
||||
'&.cm-editor': {
|
||||
'--visual-font-family':
|
||||
"'Noto Serif', 'Palatino Linotype', 'Book Antiqua', Palatino, serif !important",
|
||||
'--visual-font-size': 'calc(var(--font-size) * 1.15)',
|
||||
'& .cm-content': {
|
||||
opacity: 0,
|
||||
},
|
||||
'&.ol-cm-parsed .cm-content': {
|
||||
opacity: 1,
|
||||
transition: 'opacity 0.1s ease-out',
|
||||
},
|
||||
},
|
||||
'.cm-content.cm-content': {
|
||||
overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
|
||||
padding:
|
||||
'0 max(calc((var(--content-width) - 95ch) / 2), calc(var(--content-width) * 0.08))', // max 95 characters per line
|
||||
fontFamily: 'var(--visual-font-family)',
|
||||
fontSize: 'var(--visual-font-size)',
|
||||
},
|
||||
'.cm-cursor-primary.cm-cursor-primary': {
|
||||
fontFamily: 'var(--visual-font-family)',
|
||||
fontSize: 'var(--visual-font-size)',
|
||||
},
|
||||
'.cm-line': {
|
||||
overflowX: 'visible', // needed so the callout elements can overflow when the content has padding
|
||||
},
|
||||
'.cm-gutter': {
|
||||
opacity: '0.5',
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
fontSize: 'calc(var(--font-size) * 1.15) !important',
|
||||
},
|
||||
'.ol-cm-link-text': {
|
||||
textDecoration: 'underline',
|
||||
fontFamily: 'inherit',
|
||||
textUnderlineOffset: '2px',
|
||||
},
|
||||
'.ol-cm-monospace': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
lineHeight: 1,
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
fontVariant: 'normal',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
'.ol-cm-strong': {
|
||||
fontWeight: 700,
|
||||
},
|
||||
'.ol-cm-punctuation': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
lineHeight: 1,
|
||||
},
|
||||
'.ol-cm-brace': {
|
||||
opacity: '0.5',
|
||||
},
|
||||
'.ol-cm-math': {
|
||||
overflow: 'hidden', // stop the margin from the inner math element affecting the block height
|
||||
},
|
||||
'.ol-cm-maketitle': {
|
||||
textAlign: 'center',
|
||||
paddingBottom: '2em',
|
||||
},
|
||||
'.ol-cm-title': {
|
||||
fontSize: '1.7em',
|
||||
cursor: 'pointer',
|
||||
padding: '0.5em',
|
||||
lineHeight: 'calc(var(--line-height) * 5/6)',
|
||||
textWrap: 'balance',
|
||||
},
|
||||
'.ol-cm-authors': {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-evenly',
|
||||
gap: '0.5em',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
'.ol-cm-author': {
|
||||
cursor: 'pointer',
|
||||
display: 'inline-block',
|
||||
minWidth: '150px',
|
||||
},
|
||||
'.ol-cm-icon-brace': {
|
||||
filter: 'grayscale(1)',
|
||||
marginRight: '2px',
|
||||
},
|
||||
'.ol-cm-indicator': {
|
||||
color: 'rgba(125, 125, 125, 0.5)',
|
||||
},
|
||||
'.ol-cm-begin': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
minHeight: '1em',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
'.ol-cm-end': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
paddingBottom: '1.5em',
|
||||
minHeight: '1em',
|
||||
textAlign: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `linear-gradient(180deg, rgba(0,0,0,0) calc(50% - 1px), rgba(192,192,192,1) calc(50%), rgba(0,0,0,0) calc(50% + 1px))`,
|
||||
},
|
||||
'.ol-cm-environment-top': {
|
||||
paddingTop: '1em',
|
||||
},
|
||||
'.ol-cm-environment-bottom': {
|
||||
paddingBottom: '1em',
|
||||
},
|
||||
'.ol-cm-environment-first-line': {
|
||||
paddingTop: '0.5em !important',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
},
|
||||
'.ol-cm-environment-last-line': {
|
||||
paddingBottom: '1em !important',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
},
|
||||
'.ol-cm-environment-figure.ol-cm-environment-line, .ol-cm-environment-table.ol-cm-environment-line':
|
||||
{
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.05)',
|
||||
padding: '0 12px',
|
||||
},
|
||||
'.ol-cm-environment-figure.ol-cm-environment-last-line, .ol-cm-environment-table.ol-cm-environment-last-line, .ol-cm-preamble-line.ol-cm-environment-last-line':
|
||||
{
|
||||
boxShadow: '0 2px 5px -3px rgb(125, 125, 125, 0.5)',
|
||||
},
|
||||
'.ol-cm-environment-theorem-plain': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.ol-cm-begin-proof > .ol-cm-environment-name': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.ol-cm-environment-quote-block.ol-cm-environment-line': {
|
||||
borderLeft: '4px solid rgba(125, 125, 125, 0.25)',
|
||||
paddingLeft: '1em',
|
||||
borderRadius: '0',
|
||||
},
|
||||
'.ol-cm-environment-padding': {
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
background: `linear-gradient(180deg, rgba(0,0,0,0) calc(50% - 1px), rgba(192,192,192,1) calc(50%), rgba(0,0,0,0) calc(50% + 1px))`,
|
||||
},
|
||||
'.ol-cm-environment-name': {
|
||||
padding: '0 1em',
|
||||
},
|
||||
'.ol-cm-begin-abstract > .ol-cm-environment-name': {
|
||||
fontFamily: 'var(--visual-font-family)',
|
||||
fontSize: '1.2em',
|
||||
fontWeight: 550,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
'.ol-cm-begin-theorem > .ol-cm-environment-name': {
|
||||
fontFamily: 'var(--visual-font-family)',
|
||||
fontWeight: 550,
|
||||
padding: '0 6px',
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
'.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': {
|
||||
flex: 0,
|
||||
},
|
||||
'.ol-cm-item, .ol-cm-description-item': {
|
||||
paddingInlineStart: 'calc(var(--list-depth) * 2ch)',
|
||||
},
|
||||
'.ol-cm-item::before': {
|
||||
counterReset: 'list-item var(--list-ordinal)',
|
||||
content: 'counter(list-item, var(--list-type)) var(--list-suffix)',
|
||||
},
|
||||
'.ol-cm-heading': {
|
||||
fontWeight: 550,
|
||||
lineHeight: '1.35',
|
||||
color: 'inherit !important',
|
||||
background: 'inherit !important',
|
||||
},
|
||||
'.ol-cm-command-part': {
|
||||
fontSize: '2em',
|
||||
},
|
||||
'.ol-cm-command-chapter': {
|
||||
fontSize: '1.6em',
|
||||
},
|
||||
'.ol-cm-command-section': {
|
||||
fontSize: '1.44em',
|
||||
},
|
||||
'.ol-cm-command-subsection': {
|
||||
fontSize: '1.2em',
|
||||
},
|
||||
'.ol-cm-command-subsubsection': {
|
||||
fontSize: '1em',
|
||||
},
|
||||
'.ol-cm-command-paragraph': {
|
||||
fontSize: '1em',
|
||||
},
|
||||
'.ol-cm-command-subparagraph': {
|
||||
fontSize: '1em',
|
||||
},
|
||||
'.ol-cm-frame-title': {
|
||||
fontSize: '1.44em',
|
||||
},
|
||||
'.ol-cm-frame-subtitle': {
|
||||
fontSize: '1em',
|
||||
},
|
||||
'.ol-cm-divider': {
|
||||
borderBottom: '1px solid rgba(125, 125, 125, 0.1)',
|
||||
padding: '0.5em 6px',
|
||||
'&.ol-cm-frame-widget': {
|
||||
borderBottom: 'none',
|
||||
borderTop: '1px solid rgba(125, 125, 125, 0.1)',
|
||||
},
|
||||
},
|
||||
'.ol-cm-command-textbf': {
|
||||
fontWeight: 700,
|
||||
},
|
||||
'.ol-cm-command-textit': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.ol-cm-command-textsc': {
|
||||
fontVariant: 'small-caps',
|
||||
},
|
||||
'.ol-cm-command-texttt': {
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
'.ol-cm-command-textmd, .ol-cm-command-textmd > .ol-cm-command-textbf': {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'.ol-cm-command-textsf': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
},
|
||||
'.ol-cm-command-textsuperscript': {
|
||||
verticalAlign: 'super',
|
||||
fontSize: 'smaller',
|
||||
lineHeight: 'calc(var(--line-height) / 2)',
|
||||
},
|
||||
'.ol-cm-command-textsubscript': {
|
||||
verticalAlign: 'sub',
|
||||
fontSize: 'smaller',
|
||||
lineHeight: 'calc(var(--line-height) / 2)',
|
||||
},
|
||||
'.ol-cm-command-underline': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
'.ol-cm-command-sout': {
|
||||
textDecoration: 'line-through',
|
||||
},
|
||||
'.ol-cm-command-emph': {
|
||||
fontStyle: 'italic',
|
||||
'& .ol-cm-command-textit': {
|
||||
fontStyle: 'normal',
|
||||
},
|
||||
'.ol-cm-command-textit &': {
|
||||
fontStyle: 'normal',
|
||||
},
|
||||
},
|
||||
'.ol-cm-command-url': {
|
||||
textDecoration: 'underline',
|
||||
// copied from tags.monospace
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
lineHeight: 1,
|
||||
overflowWrap: 'break-word',
|
||||
hyphens: 'auto',
|
||||
},
|
||||
'.ol-cm-space': {
|
||||
display: 'inline-block',
|
||||
},
|
||||
'.ol-cm-environment-centered': {
|
||||
'&.ol-cm-label-line, &.ol-cm-caption-line': {
|
||||
textAlign: 'center',
|
||||
},
|
||||
'&.ol-cm-caption-line': {
|
||||
padding: '0 10%',
|
||||
},
|
||||
},
|
||||
'.ol-cm-caption-line .ol-cm-label': {
|
||||
marginRight: '1ch',
|
||||
},
|
||||
'.ol-cm-environment-verbatim': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
},
|
||||
'.ol-cm-environment-lstlisting': {
|
||||
fontFamily: 'var(--source-font-family)',
|
||||
},
|
||||
'.ol-cm-tex': {
|
||||
textTransform: 'uppercase',
|
||||
'& sup': {
|
||||
position: 'inherit',
|
||||
fontSize: '0.85em',
|
||||
verticalAlign: '0.15em',
|
||||
marginLeft: '-0.36em',
|
||||
marginRight: '-0.15em',
|
||||
},
|
||||
'& sub': {
|
||||
position: 'inherit',
|
||||
fontSize: '1em',
|
||||
verticalAlign: '-0.5ex',
|
||||
marginLeft: '-0.1667em',
|
||||
marginRight: '-0.125em',
|
||||
},
|
||||
},
|
||||
'.ol-cm-graphics': {
|
||||
display: 'block',
|
||||
maxWidth: 'min(300px, 100%)',
|
||||
paddingTop: '1em',
|
||||
paddingBottom: '1em',
|
||||
cursor: 'pointer',
|
||||
'.ol-cm-graphics-inline &': {
|
||||
display: 'inline',
|
||||
},
|
||||
},
|
||||
'.ol-cm-graphics-inline-edit-wrapper': {
|
||||
display: 'inline-block',
|
||||
position: 'relative',
|
||||
verticalAlign: 'middle',
|
||||
'& .ol-cm-graphics': {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
},
|
||||
'.ol-cm-graphics-loading': {
|
||||
height: '300px', // guess that the height is the same as the max width
|
||||
},
|
||||
'.ol-cm-graphics-error': {
|
||||
border: '1px solid red',
|
||||
padding: '8px',
|
||||
},
|
||||
'.ol-cm-environment-centered .ol-cm-graphics': {
|
||||
margin: '0 auto',
|
||||
},
|
||||
'.ol-cm-command-verb .ol-cm-monospace': {
|
||||
color: 'inherit', // remove syntax highlighting colour from verbatim content
|
||||
},
|
||||
'.ol-cm-preamble-wrapper': {
|
||||
padding: '0.5em 0',
|
||||
'&.ol-cm-preamble-expanded': {
|
||||
paddingBottom: '0',
|
||||
},
|
||||
},
|
||||
'.ol-cm-preamble-widget, .ol-cm-end-document-widget': {
|
||||
padding: '0.25em 1em',
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: '14px',
|
||||
'.ol-cm-preamble-expanded &': {
|
||||
borderBottomLeftRadius: '0',
|
||||
borderBottomRightRadius: '0',
|
||||
borderBottom: '1px solid rgba(125, 125, 125, 0.2)',
|
||||
},
|
||||
},
|
||||
'.ol-cm-preamble-widget': {
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'.ol-cm-preamble-expand-icon': {
|
||||
width: '32px',
|
||||
lineHeight: '32px',
|
||||
textAlign: 'center',
|
||||
transition: '0.2s ease-out',
|
||||
opacity: '0.5',
|
||||
'.ol-cm-preamble-widget:hover &': {
|
||||
opacity: '1',
|
||||
},
|
||||
'.ol-cm-preamble-expanded &': {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
},
|
||||
'.ol-cm-preamble-line, .ol-cm-end-document-widget, .ol-cm-preamble-widget': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.05)',
|
||||
},
|
||||
'.ol-cm-preamble-line': {
|
||||
padding: '0 12px',
|
||||
'&.ol-cm-environment-first-line': {
|
||||
borderRadius: '0',
|
||||
},
|
||||
},
|
||||
'.ol-cm-end-document-widget': {
|
||||
textAlign: 'center',
|
||||
},
|
||||
'.ol-cm-environment-figure': {
|
||||
position: 'relative',
|
||||
},
|
||||
'.ol-cm-graphics-edit-button': {
|
||||
position: 'absolute',
|
||||
top: '18px',
|
||||
right: '18px',
|
||||
},
|
||||
'.ol-cm-footnote': {
|
||||
display: 'inline-flex',
|
||||
padding: '0 0.1em',
|
||||
background: 'rgba(125, 125, 125, 0.25)',
|
||||
borderRadius: '2px',
|
||||
height: '1em',
|
||||
cursor: 'pointer',
|
||||
verticalAlign: 'text-top',
|
||||
'&:not(.ol-cm-footnote-view):hover': {
|
||||
background: 'rgba(125, 125, 125, 0.5)',
|
||||
},
|
||||
'&.ol-cm-footnote-view': {
|
||||
height: 'auto',
|
||||
verticalAlign: 'unset',
|
||||
display: 'inline',
|
||||
padding: '0 0.5em',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const contentWidthThemeConf = new Compartment()
|
||||
const changeContentWidthAnnotation = Annotation.define<boolean>()
|
||||
const currentWidth = Facet.define<string, string>({
|
||||
combine: values => {
|
||||
if (values.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return values[0]
|
||||
},
|
||||
})
|
||||
|
||||
function createContentWidthTheme(contentWidth: string) {
|
||||
return [
|
||||
currentWidth.of(contentWidth),
|
||||
EditorView.editorAttributes.of({
|
||||
style: `--content-width: ${contentWidth}`,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
const contentWidthSetter = EditorView.updateListener.of(update => {
|
||||
if (update.geometryChanged && !update.docChanged) {
|
||||
// Ignore any update triggered by this plugin
|
||||
if (
|
||||
update.transactions.some(tr =>
|
||||
tr.annotation(changeContentWidthAnnotation)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const viewWidth = `${update.view.contentDOM.offsetWidth}px`
|
||||
|
||||
const currentConfiguredWidth = update.state.facet(currentWidth)
|
||||
if (currentConfiguredWidth === viewWidth) {
|
||||
// We already have the correct width stored
|
||||
return
|
||||
}
|
||||
|
||||
update.view.dispatch({
|
||||
effects: contentWidthThemeConf.reconfigure(
|
||||
createContentWidthTheme(viewWidth)
|
||||
),
|
||||
// Set the selection explicitly to force the cursor to redraw if CM6
|
||||
// fails to spot a change in geometry, which sometimes seems to happen
|
||||
// (see #15145)
|
||||
selection: update.view.state.selection,
|
||||
annotations: changeContentWidthAnnotation.of(true),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const visualTheme: Extension = [
|
||||
contentWidthThemeConf.of(createContentWidthTheme('100%')),
|
||||
mainVisualTheme,
|
||||
contentWidthSetter,
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BeginWidget } from './begin'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { typesetNodeIntoElement } from '../utils/typeset-content'
|
||||
import { loadMathJax } from '../../../../mathjax/load-mathjax'
|
||||
|
||||
export class BeginTheoremWidget extends BeginWidget {
|
||||
constructor(
|
||||
public environment: string,
|
||||
public name: string,
|
||||
public argumentNode?: SyntaxNode | null
|
||||
) {
|
||||
super(environment)
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const element = super.toDOM(view)
|
||||
element.classList.add('ol-cm-begin-theorem')
|
||||
return element
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLDivElement, view: EditorView) {
|
||||
super.updateDOM(element, view)
|
||||
element.classList.add('ol-cm-begin-theorem')
|
||||
return true
|
||||
}
|
||||
|
||||
eq(widget: BeginTheoremWidget) {
|
||||
return (
|
||||
super.eq(widget) &&
|
||||
widget.name === this.name &&
|
||||
widget.argumentNode === this.argumentNode
|
||||
)
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
buildName(nameElement: HTMLSpanElement, view: EditorView) {
|
||||
nameElement.textContent = this.name
|
||||
if (this.argumentNode) {
|
||||
const suffixElement = document.createElement('span')
|
||||
typesetNodeIntoElement(this.argumentNode, suffixElement, view.state)
|
||||
nameElement.append(' (', suffixElement, ')')
|
||||
|
||||
loadMathJax()
|
||||
.then(async MathJax => {
|
||||
if (!this.destroyed) {
|
||||
await MathJax.typesetPromise([nameElement])
|
||||
view.requestMeasure()
|
||||
MathJax.typesetClear([nameElement])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
nameElement.classList.add('ol-cm-error')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { placeSelectionInsideBlock } from '../selection'
|
||||
|
||||
export class BeginWidget extends WidgetType {
|
||||
destroyed = false
|
||||
|
||||
constructor(public environment: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
this.destroyed = false
|
||||
const element = document.createElement('div')
|
||||
this.buildElement(element, view)
|
||||
|
||||
element.addEventListener('mouseup', event => {
|
||||
event.preventDefault()
|
||||
view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent))
|
||||
})
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: BeginWidget) {
|
||||
return widget.environment === this.environment
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLDivElement, view: EditorView) {
|
||||
this.destroyed = false
|
||||
element.textContent = ''
|
||||
element.className = ''
|
||||
this.buildElement(element, view)
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
buildName(name: HTMLSpanElement, view: EditorView) {
|
||||
name.textContent = this.environment
|
||||
}
|
||||
|
||||
buildElement(element: HTMLDivElement, view: EditorView) {
|
||||
element.classList.add('ol-cm-begin', `ol-cm-begin-${this.environment}`)
|
||||
|
||||
const startPadding = document.createElement('span')
|
||||
startPadding.classList.add(
|
||||
'ol-cm-environment-padding',
|
||||
'ol-cm-environment-start-padding'
|
||||
)
|
||||
element.appendChild(startPadding)
|
||||
|
||||
const name = document.createElement('span')
|
||||
name.classList.add('ol-cm-environment-name')
|
||||
this.buildName(name, view)
|
||||
element.appendChild(name)
|
||||
|
||||
const endPadding = document.createElement('span')
|
||||
endPadding.classList.add(
|
||||
'ol-cm-environment-padding',
|
||||
'ol-cm-environment-end-padding'
|
||||
)
|
||||
element.appendChild(endPadding)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class BraceWidget extends WidgetType {
|
||||
constructor(private content?: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-brace')
|
||||
if (this.content !== undefined) {
|
||||
element.textContent = this.content
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
eq(widget: BraceWidget) {
|
||||
return widget.content === this.content
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class CharacterWidget extends WidgetType {
|
||||
constructor(public content: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-character')
|
||||
element.textContent = this.content
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: CharacterWidget) {
|
||||
return widget.content === this.content
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement): boolean {
|
||||
element.textContent = this.content
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
export const COMMAND_SUBSTITUTIONS = new Map([
|
||||
['\\', ' '], // a trimmed \\ '
|
||||
['\\%', '\u0025'],
|
||||
['\\_', '\u005F'],
|
||||
['\\}', '\u007D'],
|
||||
['\\~', '\u007E'],
|
||||
['\\&', '\u0026'],
|
||||
['\\#', '\u0023'],
|
||||
['\\{', '\u007B'],
|
||||
['\\$', '\u0024'],
|
||||
['\\textasciicircum', '\u005E'],
|
||||
['\\textless', '\u003C'],
|
||||
['\\textasciitilde', '\u007E'],
|
||||
['\\textordfeminine', '\u00AA'],
|
||||
['\\textasteriskcentered', '\u204E'],
|
||||
['\\textordmasculine', '\u00BA'],
|
||||
['\\textbackslash', '\u005C'],
|
||||
['\\textparagraph', '\u00B6'],
|
||||
['\\textbar', '\u007C'],
|
||||
['\\textperiodcentered', '\u00B7'],
|
||||
['\\textbardbl', '\u2016'],
|
||||
['\\textpertenthousand', '\u2031'],
|
||||
['\\textperthousand', '\u2030'],
|
||||
['\\textbraceleft', '\u007B'],
|
||||
['\\textquestiondown', '\u00BF'],
|
||||
['\\textbraceright', '\u007D'],
|
||||
['\\textquotedblleft', '\u201C'],
|
||||
['\\textbullet', '\u2022'],
|
||||
['\\textquotedblright', '\u201D'],
|
||||
['\\textcopyright', '\u00A9'],
|
||||
['\\textquoteleft', '\u2018'],
|
||||
['\\textdagger', '\u2020'],
|
||||
['\\textquoteright', '\u2019'],
|
||||
['\\textdaggerdbl', '\u2021'],
|
||||
['\\textregistered', '\u00AE'],
|
||||
['\\textdollar', '\u0024'],
|
||||
['\\textsection', '\u00A7'],
|
||||
['\\textellipsis', '\u2026'],
|
||||
['\\textsterling', '\u00A3'],
|
||||
['\\textemdash', '\u2014'],
|
||||
['\\texttrademark', '\u2122'],
|
||||
['\\textendash', '\u2013'],
|
||||
['\\textunderscore', '\u005F'],
|
||||
['\\textexclamdown', '\u00A1'],
|
||||
['\\textvisiblespace', '\u2423'],
|
||||
['\\textgreater', '\u003E'],
|
||||
['\\ddag', '\u2021'],
|
||||
['\\pounds', '\u00A3'],
|
||||
['\\copyright', '\u00A9'],
|
||||
['\\dots', '\u2026'],
|
||||
['\\S', '\u00A7'],
|
||||
['\\dag', '\u2020'],
|
||||
['\\P', '\u00B6'],
|
||||
['\\aa', '\u00E5'],
|
||||
['\\DH', '\u00D0'],
|
||||
['\\L', '\u0141'],
|
||||
['\\o', '\u00F8'],
|
||||
['\\th', '\u00FE'],
|
||||
['\\AA', '\u00C5'],
|
||||
['\\DJ', '\u0110'],
|
||||
['\\l', '\u0142'],
|
||||
['\\oe', '\u0153'],
|
||||
['\\TH', '\u00DE'],
|
||||
['\\AE', '\u00C6'],
|
||||
['\\dj', '\u0111'],
|
||||
['\\NG', '\u014A'],
|
||||
['\\OE', '\u0152'],
|
||||
['\\ae', '\u00E6'],
|
||||
['\\IJ', '\u0132'],
|
||||
['\\ng', '\u014B'],
|
||||
['\\ss', '\u00DF'],
|
||||
['\\dh', '\u00F0'],
|
||||
['\\ij', '\u0133'],
|
||||
['\\O', '\u00D8'],
|
||||
['\\SS', '\u1E9E'],
|
||||
['\\guillemetleft', '\u00AB'],
|
||||
['\\guilsinglleft', '\u2039'],
|
||||
['\\quotedblbase', '\u201E'],
|
||||
['\\textquotedbl', '\u0022'],
|
||||
['\\guillemetright', '\u00BB'],
|
||||
['\\guilsinglright', '\u203A'],
|
||||
['\\quotesinglbase', '\u201A'],
|
||||
['\\textbaht', '\u0E3F'],
|
||||
['\\textdollar', '\u0024'],
|
||||
['\\textwon', '\u20A9'],
|
||||
['\\textcent', '\u00A2'],
|
||||
['\\textlira', '\u20A4'],
|
||||
['\\textyen', '\u00A5'],
|
||||
['\\textcentoldstyle', '\u00A2'],
|
||||
['\\textdong', '\u20AB'],
|
||||
['\\textnaira', '\u20A6'],
|
||||
['\\textcolonmonetary', '\u20A1'],
|
||||
['\\texteuro', '\u20AC'],
|
||||
['\\textpeso', '\u20B1'],
|
||||
['\\textcurrency', '\u00A4'],
|
||||
['\\textflorin', '\u0192'],
|
||||
['\\textsterling', '\u00A3'],
|
||||
['\\textcircledP', '\u2117'],
|
||||
['\\textcopyright', '\u00A9'],
|
||||
['\\textservicemark', '\u2120'],
|
||||
['\\textregistered', '\u00AE'],
|
||||
['\\texttrademark', '\u2122'],
|
||||
['\\textblank', '\u2422'],
|
||||
['\\textpilcrow', '\u00B6'],
|
||||
['\\textbrokenbar', '\u00A6'],
|
||||
['\\textquotesingle', '\u0027'],
|
||||
['\\textdblhyphen', '\u2E40'],
|
||||
['\\textdblhyphenchar', '\u2E40'],
|
||||
['\\textdiscount', '\u2052'],
|
||||
['\\textrecipe', '\u211E'],
|
||||
['\\textestimated', '\u212E'],
|
||||
['\\textreferencemark', '\u203B'],
|
||||
['\\textinterrobang', '\u203D'],
|
||||
['\\textthreequartersemdash', '\u2014'],
|
||||
['\\textinterrobangdown', '\u2E18'],
|
||||
['\\texttildelow', '\u02F7'],
|
||||
['\\textnumero', '\u2116'],
|
||||
['\\texttwelveudash', '\u2014'],
|
||||
['\\textopenbullet', '\u25E6'],
|
||||
['\\ldots', '\u2026'],
|
||||
])
|
||||
|
||||
export function createCharacterCommand(
|
||||
command: string
|
||||
): CharacterWidget | undefined {
|
||||
const substitution = COMMAND_SUBSTITUTIONS.get(command)
|
||||
if (substitution !== undefined) {
|
||||
return new CharacterWidget(substitution)
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCharacterSubstitution(command: string): boolean {
|
||||
return COMMAND_SUBSTITUTIONS.has(command)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class DescriptionItemWidget extends WidgetType {
|
||||
constructor(public listDepth: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-description-item')
|
||||
this.setProperties(element)
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: DescriptionItemWidget) {
|
||||
return widget.listDepth === this.listDepth
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement) {
|
||||
this.setProperties(element)
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
setProperties(element: HTMLElement) {
|
||||
element.style.setProperty('--list-depth', String(this.listDepth))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class DividerWidget extends WidgetType {
|
||||
toDOM() {
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-divider')
|
||||
return element
|
||||
}
|
||||
|
||||
eq() {
|
||||
return true
|
||||
}
|
||||
|
||||
updateDOM(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { GraphicsWidget } from './graphics'
|
||||
import { editFigureDataEffect } from '../../figure-modal'
|
||||
import { emitToolbarEvent } from '../../toolbar/utils/analytics'
|
||||
|
||||
export class EditableGraphicsWidget extends GraphicsWidget {
|
||||
setEditDispatcher(button: HTMLButtonElement, view: EditorView) {
|
||||
button.classList.toggle('hidden', !this.figureData)
|
||||
if (this.figureData) {
|
||||
button.onmousedown = event => {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
view.dispatch({ effects: editFigureDataEffect.of(this.figureData) })
|
||||
window.dispatchEvent(new CustomEvent('figure-modal:open-modal'))
|
||||
emitToolbarEvent(view, 'toolbar-figure-modal-edit')
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
button.onmousedown = null
|
||||
}
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLImageElement, view: EditorView): boolean {
|
||||
this.destroyed = false
|
||||
element.classList.toggle('ol-cm-environment-centered', this.centered)
|
||||
if (
|
||||
this.filePath === element.dataset.filepath &&
|
||||
element.dataset.width === String(this.figureData?.width?.toString())
|
||||
) {
|
||||
// Figure remained the same, so just update the event listener on the button
|
||||
const button = element.querySelector<HTMLButtonElement>(
|
||||
'.ol-cm-graphics-edit-button'
|
||||
)
|
||||
if (button) {
|
||||
this.setEditDispatcher(button, view)
|
||||
}
|
||||
return true
|
||||
}
|
||||
this.renderGraphic(element, view)
|
||||
view.requestMeasure()
|
||||
return true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
createEditButton(view: EditorView) {
|
||||
const button = document.createElement('button')
|
||||
button.setAttribute('aria-label', view.state.phrase('edit_figure'))
|
||||
this.setEditDispatcher(button, view)
|
||||
button.classList.add('btn', 'btn-secondary', 'ol-cm-graphics-edit-button')
|
||||
const buttonLabel = document.createElement('span')
|
||||
buttonLabel.classList.add('fa', 'fa-pencil')
|
||||
button.append(buttonLabel)
|
||||
return button
|
||||
}
|
||||
|
||||
renderGraphic(element: HTMLElement, view: EditorView) {
|
||||
super.renderGraphic(element, view)
|
||||
if (this.figureData) {
|
||||
const button = this.createEditButton(view)
|
||||
element.prepend(button)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditableGraphicsWidget } from './editable-graphics'
|
||||
|
||||
export class EditableInlineGraphicsWidget extends EditableGraphicsWidget {
|
||||
updateElementData(element: HTMLElement) {
|
||||
element.dataset.filepath = this.filePath
|
||||
element.dataset.width = this.figureData?.width?.toString()
|
||||
if (this.figureData?.width) {
|
||||
element.style.width = `min(100%, ${this.figureData.width * 100}%)`
|
||||
} else {
|
||||
element.style.width = ''
|
||||
}
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
this.destroyed = false
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-graphics-inline-edit-wrapper')
|
||||
this.updateElementData(element)
|
||||
const inlineElement = document.createElement('span')
|
||||
inlineElement.classList.add('ol-cm-graphics-inline')
|
||||
this.renderGraphic(inlineElement, view)
|
||||
element.append(inlineElement)
|
||||
return element
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLImageElement, view: EditorView): boolean {
|
||||
const updated = super.updateDOM(element, view)
|
||||
if (!updated) {
|
||||
return false
|
||||
}
|
||||
// We need to make sure these are updated, as `renderGraphic` in the base
|
||||
// class will update them on the inner element.
|
||||
this.updateElementData(element)
|
||||
view.requestMeasure()
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
// We set the actual figure width on the span rather than the img element
|
||||
getFigureWidth(): string {
|
||||
return '100%'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { placeSelectionInsideBlock } from '../selection'
|
||||
|
||||
export class EndDocumentWidget extends WidgetType {
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-end-document-widget')
|
||||
element.textContent = view.state.phrase('end_of_document')
|
||||
element.addEventListener('mouseup', event => {
|
||||
event.preventDefault()
|
||||
view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent))
|
||||
})
|
||||
return element
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
eq(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class EndWidget extends WidgetType {
|
||||
toDOM() {
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-end')
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: EndWidget) {
|
||||
return true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
|
||||
export class EnvironmentLineWidget extends WidgetType {
|
||||
constructor(
|
||||
public environment: string,
|
||||
public line?: 'begin' | 'end'
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const element = document.createElement('div')
|
||||
element.classList.add(`ol-cm-environment-${this.environment}`)
|
||||
element.classList.add('ol-cm-environment-edge')
|
||||
|
||||
const line = document.createElement('div')
|
||||
element.append(line)
|
||||
|
||||
line.classList.add('ol-cm-environment-line')
|
||||
line.classList.add(`ol-cm-environment-${this.environment}`)
|
||||
switch (this.line) {
|
||||
case 'begin':
|
||||
element.classList.add('ol-cm-environment-top')
|
||||
line.classList.add('ol-cm-environment-first-line')
|
||||
break
|
||||
case 'end':
|
||||
element.classList.add('ol-cm-environment-bottom')
|
||||
line.classList.add('ol-cm-environment-last-line')
|
||||
break
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: EnvironmentLineWidget) {
|
||||
return widget.environment === this.environment && widget.line === this.line
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
type NoteType = 'footnote' | 'endnote'
|
||||
|
||||
const symbols: Record<NoteType, string> = {
|
||||
footnote: '*',
|
||||
endnote: '†',
|
||||
}
|
||||
|
||||
export class FootnoteWidget extends WidgetType {
|
||||
constructor(private type: NoteType = 'footnote') {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-footnote')
|
||||
element.setAttribute('role', 'button')
|
||||
element.innerHTML = symbols[this.type]
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: FootnoteWidget) {
|
||||
return this.type === widget.type
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement): boolean {
|
||||
element.innerHTML = symbols[this.type]
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { loadMathJax } from '../../../../mathjax/load-mathjax'
|
||||
import { selectNode } from '../utils/select-node'
|
||||
import { typesetNodeIntoElement } from '../utils/typeset-content'
|
||||
|
||||
export type Frame = {
|
||||
title: {
|
||||
node: SyntaxNode
|
||||
content: string
|
||||
}
|
||||
subtitle?: {
|
||||
node: SyntaxNode
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
export class FrameWidget extends WidgetType {
|
||||
destroyed = false
|
||||
|
||||
constructor(public frame: Frame) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
this.destroyed = false
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-frame-widget', 'ol-cm-divider')
|
||||
|
||||
const title = document.createElement('div')
|
||||
title.classList.add('ol-cm-frame-title', 'ol-cm-heading')
|
||||
title.addEventListener('mouseup', () =>
|
||||
selectNode(view, this.frame.title.node)
|
||||
)
|
||||
typesetNodeIntoElement(this.frame.title.node, title, view.state)
|
||||
element.appendChild(title)
|
||||
|
||||
if (this.frame.subtitle) {
|
||||
const subtitle = document.createElement('div')
|
||||
subtitle.classList.add('ol-cm-frame-subtitle', 'ol-cm-heading')
|
||||
typesetNodeIntoElement(this.frame.subtitle.node, subtitle, view.state)
|
||||
subtitle.addEventListener('mouseup', () =>
|
||||
selectNode(view, this.frame.subtitle!.node)
|
||||
)
|
||||
element.appendChild(subtitle)
|
||||
}
|
||||
|
||||
// render equations
|
||||
loadMathJax()
|
||||
.then(async MathJax => {
|
||||
if (!this.destroyed) {
|
||||
await MathJax.typesetPromise([element])
|
||||
view.requestMeasure()
|
||||
MathJax.typesetClear([element])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
element.classList.add('ol-cm-error')
|
||||
})
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
eq(other: FrameWidget): boolean {
|
||||
return (
|
||||
other.frame.title.content === this.frame.title.content &&
|
||||
other.frame.subtitle?.content === this.frame.subtitle?.content
|
||||
)
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { placeSelectionInsideBlock } from '../selection'
|
||||
import { isEqual } from 'lodash'
|
||||
import { FigureData } from '../../figure-modal'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { PreviewPath } from '../../../../../../../types/preview-path'
|
||||
|
||||
export class GraphicsWidget extends WidgetType {
|
||||
destroyed = false
|
||||
height = 300 // for estimatedHeight, updated when the image is loaded
|
||||
|
||||
constructor(
|
||||
public filePath: string,
|
||||
public previewByPath: (path: string) => PreviewPath | null,
|
||||
public centered: boolean,
|
||||
public figureData: FigureData | null
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
this.destroyed = false
|
||||
|
||||
// this is a block decoration, so it's outside the line decorations from the environment
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-environment-figure')
|
||||
element.classList.add('ol-cm-environment-line')
|
||||
element.classList.toggle('ol-cm-environment-centered', this.centered)
|
||||
|
||||
this.renderGraphic(element, view)
|
||||
|
||||
element.addEventListener('mouseup', event => {
|
||||
event.preventDefault()
|
||||
view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent))
|
||||
})
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: GraphicsWidget) {
|
||||
return (
|
||||
widget.filePath === this.filePath &&
|
||||
widget.centered === this.centered &&
|
||||
isEqual(this.figureData, widget.figureData)
|
||||
)
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLImageElement, view: EditorView) {
|
||||
this.destroyed = false
|
||||
element.classList.toggle('ol-cm-environment-centered', this.centered)
|
||||
if (
|
||||
this.filePath === element.dataset.filepath &&
|
||||
element.dataset.width === String(this.figureData?.width?.toString())
|
||||
) {
|
||||
return true
|
||||
}
|
||||
this.renderGraphic(element, view)
|
||||
view.requestMeasure()
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return (
|
||||
event.type !== 'mouseup' &&
|
||||
// Pass events through to the edit button
|
||||
!(
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest('.ol-cm-graphics-edit-button')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
get estimatedHeight(): number {
|
||||
return this.height
|
||||
}
|
||||
|
||||
renderGraphic(element: HTMLElement, view: EditorView) {
|
||||
element.textContent = '' // ensure the element is empty
|
||||
|
||||
const preview = this.previewByPath(this.filePath)
|
||||
element.dataset.filepath = this.filePath
|
||||
element.dataset.width = this.figureData?.width?.toString()
|
||||
|
||||
if (!preview) {
|
||||
const message = document.createElement('div')
|
||||
message.classList.add('ol-cm-graphics-error')
|
||||
message.classList.add('ol-cm-monospace')
|
||||
message.textContent = this.filePath
|
||||
element.append(message)
|
||||
return
|
||||
}
|
||||
|
||||
switch (preview.extension) {
|
||||
case 'pdf':
|
||||
{
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.classList.add('ol-cm-graphics')
|
||||
this.renderPDF(view, canvas, preview.url).catch(debugConsole.error)
|
||||
element.append(canvas)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
element.append(this.createImage(view, preview.url))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
getFigureWidth() {
|
||||
if (this.figureData?.width) {
|
||||
return `min(100%, ${this.figureData.width * 100}%)`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
createImage(view: EditorView, url: string) {
|
||||
const image = document.createElement('img')
|
||||
image.classList.add('ol-cm-graphics')
|
||||
image.classList.add('ol-cm-graphics-loading')
|
||||
const width = this.getFigureWidth()
|
||||
image.style.width = width
|
||||
image.style.maxWidth = width
|
||||
|
||||
image.src = url
|
||||
image.addEventListener('load', () => {
|
||||
image.classList.remove('ol-cm-graphics-loading')
|
||||
this.height = image.height // for estimatedHeight
|
||||
view.requestMeasure()
|
||||
})
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
async renderPDF(view: EditorView, canvas: HTMLCanvasElement, url: string) {
|
||||
const { loadPdfDocumentFromUrl } = await import(
|
||||
'@/features/pdf-preview/util/pdf-js'
|
||||
)
|
||||
|
||||
// bail out if loading PDF.js took too long
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const pdf = await loadPdfDocumentFromUrl(url).promise
|
||||
const page = await pdf.getPage(1)
|
||||
|
||||
// bail out if loading the PDF took too long
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const viewport = page.getViewport({ scale: 1 })
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
const width = this.getFigureWidth()
|
||||
canvas.style.width = width
|
||||
canvas.style.maxWidth = width
|
||||
page.render({
|
||||
canvasContext: canvas.getContext('2d')!,
|
||||
viewport,
|
||||
})
|
||||
this.height = viewport.height
|
||||
view.requestMeasure()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class IconBraceWidget extends WidgetType {
|
||||
constructor(private content?: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-brace')
|
||||
element.classList.add('ol-cm-icon-brace')
|
||||
if (this.content !== undefined) {
|
||||
element.textContent = this.content
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
eq(widget: IconBraceWidget) {
|
||||
return widget.content === this.content
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement): boolean {
|
||||
element.textContent = this.content ?? ''
|
||||
return true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class IndicatorWidget extends WidgetType {
|
||||
constructor(public content: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-indicator')
|
||||
element.textContent = this.content
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: IndicatorWidget) {
|
||||
return widget.content === this.content
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement): boolean {
|
||||
element.textContent = this.content
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { GraphicsWidget } from './graphics'
|
||||
|
||||
export class InlineGraphicsWidget extends GraphicsWidget {
|
||||
toDOM(view: EditorView) {
|
||||
this.destroyed = false
|
||||
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-graphics-inline')
|
||||
|
||||
this.renderGraphic(element, view)
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
import { ListEnvironmentName } from '../../../utils/tree-operations/ancestors'
|
||||
|
||||
export class ItemWidget extends WidgetType {
|
||||
public listType: string
|
||||
public suffix: string
|
||||
|
||||
bullets: string[] = ['disc', 'circle', 'square']
|
||||
numbers: string[] = ['decimal', 'lower-alpha', 'lower-roman', 'upper-alpha']
|
||||
|
||||
constructor(
|
||||
public currentEnvironment: ListEnvironmentName | 'document',
|
||||
public ordinal: number,
|
||||
public listDepth: number
|
||||
) {
|
||||
super()
|
||||
|
||||
if (currentEnvironment === 'itemize') {
|
||||
// unordered list
|
||||
this.listType = this.bullets[(listDepth - 1) % this.bullets.length]
|
||||
this.suffix = "' '"
|
||||
} else {
|
||||
// ordered list
|
||||
this.listType = this.numbers[(listDepth - 1) % this.numbers.length]
|
||||
this.suffix = "'. '"
|
||||
}
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-item')
|
||||
element.textContent = ' ' // a space, so the line has width
|
||||
this.setProperties(element)
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: ItemWidget) {
|
||||
return (
|
||||
widget.currentEnvironment === this.currentEnvironment &&
|
||||
widget.ordinal === this.ordinal &&
|
||||
widget.listDepth === this.listDepth
|
||||
)
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement) {
|
||||
this.setProperties(element)
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
setProperties(element: HTMLElement) {
|
||||
element.style.setProperty('--list-depth', String(this.listDepth))
|
||||
element.style.setProperty('--list-ordinal', String(this.ordinal))
|
||||
element.style.setProperty('--list-type', this.listType)
|
||||
element.style.setProperty('--list-suffix', this.suffix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class LaTeXWidget extends WidgetType {
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-tex')
|
||||
element.innerHTML = 'L<sup>a</sup>T<sub>e</sub>X'
|
||||
return element
|
||||
}
|
||||
|
||||
eq() {
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { loadMathJax } from '../../../../mathjax/load-mathjax'
|
||||
import { selectNode } from '../utils/select-node'
|
||||
import { typesetNodeIntoElement } from '../utils/typeset-content'
|
||||
|
||||
type Preamble = {
|
||||
title?: {
|
||||
node: SyntaxNode
|
||||
content: string
|
||||
}
|
||||
authors: {
|
||||
node: SyntaxNode
|
||||
content: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export class MakeTitleWidget extends WidgetType {
|
||||
destroyed = false
|
||||
|
||||
constructor(public preamble: Preamble) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
this.destroyed = false
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-maketitle')
|
||||
this.buildContent(view, element)
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: MakeTitleWidget) {
|
||||
return isShallowEqualPreamble(widget.preamble, this.preamble)
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement, view: EditorView): boolean {
|
||||
this.destroyed = false
|
||||
element.textContent = ''
|
||||
this.buildContent(view, element)
|
||||
view.requestMeasure()
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
buildContent(view: EditorView, element: HTMLElement) {
|
||||
if (this.preamble.title) {
|
||||
const titleElement = buildTitleElement(
|
||||
view.state,
|
||||
this.preamble.title.node
|
||||
)
|
||||
titleElement.addEventListener('mouseup', () => {
|
||||
if (this.preamble.title) {
|
||||
selectNode(view, this.preamble.title.node)
|
||||
}
|
||||
})
|
||||
element.append(titleElement)
|
||||
|
||||
// render equations
|
||||
loadMathJax()
|
||||
.then(async MathJax => {
|
||||
if (!this.destroyed) {
|
||||
await MathJax.typesetPromise([element])
|
||||
view.requestMeasure()
|
||||
MathJax.typesetClear([element])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
element.classList.add('ol-cm-error')
|
||||
})
|
||||
}
|
||||
|
||||
if (this.preamble.authors.length) {
|
||||
const authorsElement = buildAuthorsElement(this.preamble.authors, view)
|
||||
element.append(authorsElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isShallowEqualPreamble(a: Preamble, b: Preamble) {
|
||||
if (a.title?.content !== b.title?.content) {
|
||||
return false // title changed
|
||||
}
|
||||
|
||||
if (a.authors.length !== b.authors.length) {
|
||||
return false // number of authors changed
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.authors.length; i++) {
|
||||
if (a.authors[i].content !== b.authors[i].content) {
|
||||
return false // author changed
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function buildTitleElement(
|
||||
state: EditorState,
|
||||
argumentNode: SyntaxNode
|
||||
): HTMLDivElement {
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-title')
|
||||
typesetNodeIntoElement(argumentNode, element, state)
|
||||
return element
|
||||
}
|
||||
|
||||
function buildAuthorsElement(
|
||||
authors: { node: SyntaxNode; content: string }[],
|
||||
view: EditorView
|
||||
) {
|
||||
const authorsElement = document.createElement('div')
|
||||
authorsElement.classList.add('ol-cm-authors')
|
||||
|
||||
for (const { node } of authors) {
|
||||
const typesettedAuthors = document.createElement('div')
|
||||
typesetNodeIntoElement(node, typesettedAuthors, view.state)
|
||||
|
||||
let currentAuthor = document.createElement('div')
|
||||
currentAuthor.classList.add('ol-cm-author')
|
||||
authorsElement.append(currentAuthor)
|
||||
|
||||
while (typesettedAuthors.firstChild) {
|
||||
const child = typesettedAuthors.firstChild
|
||||
if (
|
||||
child instanceof HTMLElement &&
|
||||
child.classList.contains('ol-cm-command-and')
|
||||
) {
|
||||
currentAuthor = document.createElement('div')
|
||||
currentAuthor.classList.add('ol-cm-author')
|
||||
authorsElement.append(currentAuthor)
|
||||
child.remove()
|
||||
} else {
|
||||
currentAuthor.append(child)
|
||||
}
|
||||
}
|
||||
|
||||
currentAuthor.addEventListener('mouseup', () => {
|
||||
selectNode(view, node)
|
||||
})
|
||||
}
|
||||
|
||||
return authorsElement
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { loadMathJax } from '@/features/mathjax/load-mathjax'
|
||||
import { placeSelectionInsideBlock } from '../selection'
|
||||
|
||||
export class MathWidget extends WidgetType {
|
||||
destroyed = false
|
||||
|
||||
constructor(
|
||||
public math: string,
|
||||
public displayMode: boolean,
|
||||
public preamble?: string
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
this.destroyed = false
|
||||
const element = document.createElement(this.displayMode ? 'div' : 'span')
|
||||
element.classList.add('ol-cm-math')
|
||||
element.style.height = this.estimatedHeight + 'px'
|
||||
if (this.displayMode) {
|
||||
element.addEventListener('mouseup', event => {
|
||||
event.preventDefault()
|
||||
view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent))
|
||||
})
|
||||
}
|
||||
this.renderMath(element)
|
||||
.catch(() => {
|
||||
element.classList.add('ol-cm-math-error')
|
||||
})
|
||||
.finally(() => {
|
||||
view.requestMeasure()
|
||||
})
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: MathWidget) {
|
||||
return (
|
||||
widget.math === this.math &&
|
||||
widget.displayMode === this.displayMode &&
|
||||
widget.preamble === this.preamble
|
||||
)
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement, view: EditorView) {
|
||||
this.destroyed = false
|
||||
this.renderMath(element)
|
||||
.catch(() => {
|
||||
element.classList.add('ol-cm-math-error')
|
||||
})
|
||||
.finally(() => {
|
||||
view.requestMeasure()
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
// always enable mouseup to release the decorations
|
||||
if (event.type === 'mouseup') {
|
||||
return false
|
||||
}
|
||||
|
||||
// inline math needs mousedown to set the selection
|
||||
if (!this.displayMode && event.type === 'mousedown') {
|
||||
return false
|
||||
}
|
||||
|
||||
// ignore other events
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return this.math.split('\n').length * 40
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
async renderMath(element: HTMLElement) {
|
||||
const MathJax = await loadMathJax()
|
||||
|
||||
// abandon if the widget has been destroyed
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
MathJax.texReset([0]) // equation numbering is disabled, but this is still needed
|
||||
if (this.preamble) {
|
||||
try {
|
||||
await MathJax.tex2svgPromise(this.preamble)
|
||||
} catch {
|
||||
// ignore errors thrown during parsing command definitions
|
||||
}
|
||||
}
|
||||
|
||||
// abandon if the element has been removed from the DOM
|
||||
if (!element.isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
const math = await MathJax.tex2svgPromise(this.math, {
|
||||
...MathJax.getMetricsFor(element, this.displayMode),
|
||||
display: this.displayMode,
|
||||
})
|
||||
element.replaceChildren(math)
|
||||
element.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { EditorSelection, StateEffect } from '@codemirror/state'
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
export type Preamble = {
|
||||
from: number
|
||||
to: number
|
||||
title?: {
|
||||
node: SyntaxNode
|
||||
content: string
|
||||
}
|
||||
authors: {
|
||||
node: SyntaxNode
|
||||
content: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export const collapsePreambleEffect = StateEffect.define<boolean>()
|
||||
|
||||
export class PreambleWidget extends WidgetType {
|
||||
constructor(public expanded: boolean) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.classList.add('ol-cm-preamble-wrapper')
|
||||
wrapper.classList.toggle('ol-cm-preamble-expanded', this.expanded)
|
||||
const element = document.createElement('div')
|
||||
wrapper.appendChild(element)
|
||||
element.classList.add('ol-cm-preamble-widget')
|
||||
const expandIcon = document.createElement('i')
|
||||
expandIcon.classList.add(
|
||||
'ol-cm-preamble-expand-icon',
|
||||
'fa',
|
||||
'fa-chevron-down'
|
||||
)
|
||||
const helpText = document.createElement('div')
|
||||
const helpLink = document.createElement('a')
|
||||
helpLink.href =
|
||||
'/learn/latex/Learn_LaTeX_in_30_minutes#The_preamble_of_a_document'
|
||||
helpLink.target = '_blank'
|
||||
const icon = document.createElement('i')
|
||||
icon.classList.add('fa', 'fa-question-circle')
|
||||
icon.title = view.state.phrase('learn_more')
|
||||
helpLink.appendChild(icon)
|
||||
const textNode = document.createElement('span')
|
||||
textNode.classList.add('ol-cm-preamble-text')
|
||||
textNode.textContent = this.getToggleText(view)
|
||||
helpText.appendChild(textNode)
|
||||
if (this.expanded) {
|
||||
helpText.append(document.createTextNode(' '), helpLink)
|
||||
}
|
||||
element.append(helpText, expandIcon)
|
||||
|
||||
element.addEventListener('mouseup', (event: MouseEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return true
|
||||
}
|
||||
if (helpLink.contains(event.target as Node | null)) {
|
||||
return true
|
||||
}
|
||||
event.preventDefault()
|
||||
if (this.expanded) {
|
||||
view.dispatch({
|
||||
effects: collapsePreambleEffect.of(true),
|
||||
})
|
||||
} else {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(0),
|
||||
scrollIntoView: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
eq(other: PreambleWidget): boolean {
|
||||
return this.expanded === other.expanded
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return this.expanded ? -1 : 54
|
||||
}
|
||||
|
||||
getToggleText(view: EditorView) {
|
||||
if (this.expanded) {
|
||||
return view.state.phrase(`hide_document_preamble`)
|
||||
}
|
||||
return view.state.phrase(`show_document_preamble`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class SpaceWidget extends WidgetType {
|
||||
constructor(public width: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-space')
|
||||
element.style.width = this.width
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: SpaceWidget) {
|
||||
return widget.width === this.width
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement): boolean {
|
||||
element.style.width = this.width
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
// https://tex.stackexchange.com/a/74354
|
||||
export const COMMAND_WIDTHS = new Map([
|
||||
// thin space
|
||||
['\\thinspace', 'calc(3em / 18)'],
|
||||
['\\,', 'calc(3em / 18)'],
|
||||
// negative thin space
|
||||
['\\negthinspace', 'calc(-3em / 18)'],
|
||||
['\\!', 'calc(-3em / 18)'],
|
||||
// medium space
|
||||
['\\medspace', 'calc(4em / 18)'],
|
||||
['\\:', 'calc(4em / 18)'],
|
||||
['\\>', 'calc(4em / 18)'],
|
||||
// thick space
|
||||
['\\thickspace', 'calc(5em / 18)'],
|
||||
['\\;', 'calc(5em / 18)'],
|
||||
// negative thick space
|
||||
['\\negthickspace', 'calc(-5em / 18)'],
|
||||
// en, em and 2xem spaces
|
||||
['\\enspace', '0.5em'],
|
||||
['\\quad', '1em'],
|
||||
['\\qquad', '2em'],
|
||||
])
|
||||
|
||||
export function createSpaceCommand(command: string): SpaceWidget | undefined {
|
||||
const width = COMMAND_WIDTHS.get(command)
|
||||
if (width !== undefined) {
|
||||
return new SpaceWidget(width)
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSpaceSubstitution(command: string): boolean {
|
||||
return COMMAND_WIDTHS.has(command)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
export class TableRenderingErrorWidget extends WidgetType {
|
||||
private hasTableNode: boolean
|
||||
constructor(tableNode: SyntaxNode | null | undefined) {
|
||||
super()
|
||||
this.hasTableNode = Boolean(tableNode)
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const warning = document.createElement('div')
|
||||
warning.classList.add('notification', 'notification-type-info')
|
||||
warning.role = 'alert'
|
||||
const icon = document.createElement('div')
|
||||
icon.classList.add('notification-icon')
|
||||
const iconType = document.createElement('span')
|
||||
iconType.classList.add('material-symbols')
|
||||
iconType.setAttribute('aria-hidden', 'true')
|
||||
iconType.textContent = 'info'
|
||||
icon.appendChild(iconType)
|
||||
warning.appendChild(icon)
|
||||
const messageWrapper = document.createElement('div')
|
||||
messageWrapper.classList.add('notification-content-and-cta')
|
||||
const message = document.createElement('div')
|
||||
message.classList.add('notification-content')
|
||||
const messageHeader = document.createElement('p')
|
||||
const messageHeaderInner = document.createElement('strong')
|
||||
messageHeaderInner.textContent = view.state.phrase(
|
||||
'sorry_your_table_cant_be_displayed_at_the_moment'
|
||||
)
|
||||
messageHeader.appendChild(messageHeaderInner)
|
||||
const messageBody = document.createElement('p')
|
||||
messageBody.textContent = view.state.phrase(
|
||||
'this_could_be_because_we_cant_support_some_elements_of_the_table'
|
||||
)
|
||||
message.appendChild(messageHeader)
|
||||
message.appendChild(messageBody)
|
||||
messageWrapper.appendChild(message)
|
||||
warning.appendChild(messageWrapper)
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('table-generator', 'table-generator-error-container')
|
||||
element.appendChild(warning)
|
||||
if (this.hasTableNode) {
|
||||
element.classList.add('ol-cm-environment-table')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import { Tabular } from '../../../components/table-generator/tabular'
|
||||
import { ParsedTableData } from '../../../components/table-generator/utils'
|
||||
|
||||
export class TabularWidget extends WidgetType {
|
||||
constructor(
|
||||
private parsedTableData: ParsedTableData,
|
||||
private tabularNode: SyntaxNode,
|
||||
private content: string,
|
||||
private tableNode: SyntaxNode | null,
|
||||
private isDirectChildOfTableEnvironment: boolean
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const element = document.createElement('div')
|
||||
element.classList.add('ol-cm-tabular')
|
||||
if (this.tableNode) {
|
||||
element.classList.add('ol-cm-environment-table')
|
||||
}
|
||||
ReactDOM.render(
|
||||
<Tabular
|
||||
view={view}
|
||||
tabularNode={this.tabularNode}
|
||||
parsedTableData={this.parsedTableData}
|
||||
tableNode={this.tableNode}
|
||||
directTableChild={this.isDirectChildOfTableEnvironment}
|
||||
/>,
|
||||
element
|
||||
)
|
||||
return element
|
||||
}
|
||||
|
||||
eq(widget: TabularWidget): boolean {
|
||||
return (
|
||||
this.tabularNode.from === widget.tabularNode.from &&
|
||||
this.tableNode?.from === widget.tableNode?.from &&
|
||||
this.tableNode?.to === widget.tableNode?.to &&
|
||||
this.content === widget.content &&
|
||||
this.isDirectChildOfTableEnvironment ===
|
||||
widget.isDirectChildOfTableEnvironment
|
||||
)
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement, view: EditorView): boolean {
|
||||
ReactDOM.render(
|
||||
<Tabular
|
||||
view={view}
|
||||
tabularNode={this.tabularNode}
|
||||
parsedTableData={this.parsedTableData}
|
||||
tableNode={this.tableNode}
|
||||
directTableChild={this.isDirectChildOfTableEnvironment}
|
||||
/>,
|
||||
element
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
|
||||
get estimatedHeight() {
|
||||
return this.parsedTableData.table.rows.length * 50
|
||||
}
|
||||
|
||||
destroy(element: HTMLElement) {
|
||||
ReactDOM.unmountComponentAtNode(element)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class TeXWidget extends WidgetType {
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('ol-cm-tex')
|
||||
element.innerHTML = 'T<sub>e</sub>X'
|
||||
return element
|
||||
}
|
||||
|
||||
eq() {
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event) {
|
||||
return event.type !== 'mousedown' && event.type !== 'mouseup'
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
|
||||
export class TildeWidget extends WidgetType {
|
||||
toDOM() {
|
||||
const element = document.createElement('span')
|
||||
element.textContent = '\xa0' // ' ' but not using innerHTML
|
||||
return element
|
||||
}
|
||||
|
||||
eq() {
|
||||
return true
|
||||
}
|
||||
|
||||
coordsAt(element: HTMLElement) {
|
||||
return element.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
Compartment,
|
||||
EditorState,
|
||||
Extension,
|
||||
StateEffect,
|
||||
StateField,
|
||||
TransactionSpec,
|
||||
} from '@codemirror/state'
|
||||
import { visualHighlightStyle, visualTheme } from './visual-theme'
|
||||
import { atomicDecorations } from './atomic-decorations'
|
||||
import { markDecorations } from './mark-decorations'
|
||||
import { EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import { visualKeymap } from './visual-keymap'
|
||||
import { mousedown, mouseDownEffect } from './selection'
|
||||
import { forceParsing, syntaxTree } from '@codemirror/language'
|
||||
import { hasLanguageLoadedEffect } from '../language'
|
||||
import { restoreScrollPosition } from '../scroll-position'
|
||||
import { listItemMarker } from './list-item-marker'
|
||||
import { pasteHtml } from './paste-html'
|
||||
import { commandTooltip } from '../command-tooltip'
|
||||
import { tableGeneratorTheme } from './table-generator'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { PreviewPath } from '../../../../../../types/preview-path'
|
||||
|
||||
type Options = {
|
||||
visual: boolean
|
||||
previewByPath: (path: string) => PreviewPath | null
|
||||
}
|
||||
|
||||
const visualConf = new Compartment()
|
||||
|
||||
export const toggleVisualEffect = StateEffect.define<boolean>()
|
||||
|
||||
const visualState = StateField.define<boolean>({
|
||||
create() {
|
||||
return false
|
||||
},
|
||||
update(value, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(toggleVisualEffect)) {
|
||||
return effect.value
|
||||
}
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
const configureVisualExtensions = (options: Options) =>
|
||||
options.visual ? extension(options) : []
|
||||
|
||||
export const visual = (options: Options): Extension => {
|
||||
return [
|
||||
visualState.init(() => options.visual),
|
||||
visualConf.of(configureVisualExtensions(options)),
|
||||
]
|
||||
}
|
||||
|
||||
export const isVisual = (view: EditorView) => {
|
||||
return view.state.field(visualState, false) || false
|
||||
}
|
||||
|
||||
export const setVisual = (options: Options): TransactionSpec => {
|
||||
return {
|
||||
effects: [
|
||||
toggleVisualEffect.of(options.visual),
|
||||
visualConf.reconfigure(configureVisualExtensions(options)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const sourceOnly = (visual: boolean, extension: Extension) => {
|
||||
const conf = new Compartment()
|
||||
const configure = (visual: boolean) => (visual ? [] : extension)
|
||||
return [
|
||||
conf.of(configure(visual)),
|
||||
|
||||
// Respond to switching editor modes
|
||||
EditorState.transactionExtender.of(tr => {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(toggleVisualEffect)) {
|
||||
return {
|
||||
effects: conf.reconfigure(configure(effect.value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
const parsedAttributesConf = new Compartment()
|
||||
|
||||
/**
|
||||
* A view plugin which shows the editor content, makes it focusable,
|
||||
* and restores the scroll position, once the initial decorations have been applied.
|
||||
*/
|
||||
const showContentWhenParsed = [
|
||||
parsedAttributesConf.of([EditorView.editable.of(false)]),
|
||||
ViewPlugin.define(view => {
|
||||
const showContent = () => {
|
||||
view.dispatch(
|
||||
{
|
||||
effects: parsedAttributesConf.reconfigure([
|
||||
EditorView.editorAttributes.of({
|
||||
class: 'ol-cm-parsed',
|
||||
}),
|
||||
EditorView.editable.of(true),
|
||||
]),
|
||||
},
|
||||
restoreScrollPosition()
|
||||
)
|
||||
view.focus()
|
||||
}
|
||||
|
||||
// already parsed
|
||||
if (syntaxTree(view.state).length === view.state.doc.length) {
|
||||
window.setTimeout(showContent)
|
||||
return {}
|
||||
}
|
||||
|
||||
// as a fallback, make sure the content is visible after 5s
|
||||
const fallbackTimer = window.setTimeout(showContent, 5000)
|
||||
|
||||
let languageLoaded = false
|
||||
|
||||
return {
|
||||
update(update) {
|
||||
// wait for the language to load before telling the parser to run
|
||||
if (!languageLoaded && hasLanguageLoadedEffect(update)) {
|
||||
languageLoaded = true
|
||||
// in a timeout, as this is already in a dispatch cycle
|
||||
window.setTimeout(() => {
|
||||
// run asynchronously
|
||||
new Promise(() => {
|
||||
// tell the parser to run until the end of the document
|
||||
forceParsing(view, view.state.doc.length, Infinity)
|
||||
// clear the fallback timeout
|
||||
window.clearTimeout(fallbackTimer)
|
||||
// show the content, in a timeout so the decorations can build first
|
||||
window.setTimeout(showContent)
|
||||
}).catch(debugConsole.error)
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
/**
|
||||
* A transaction extender which scrolls mouse clicks into view, in case decorations have moved the cursor out of view.
|
||||
*/
|
||||
const scrollJumpAdjuster = EditorState.transactionExtender.of(tr => {
|
||||
// Attach a "scrollIntoView" effect on all mouse selections to adjust for
|
||||
// any jumps that may occur when hiding/showing decorations.
|
||||
if (!tr.scrollIntoView) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(mouseDownEffect) && effect.value === false) {
|
||||
return {
|
||||
effects: EditorView.scrollIntoView(tr.newSelection.main.head),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const extension = (options: Options) => [
|
||||
visualTheme,
|
||||
visualHighlightStyle,
|
||||
mousedown,
|
||||
listItemMarker,
|
||||
atomicDecorations(options),
|
||||
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
|
||||
visualKeymap,
|
||||
commandTooltip,
|
||||
scrollJumpAdjuster,
|
||||
showContentWhenParsed,
|
||||
pasteHtml,
|
||||
tableGeneratorTheme,
|
||||
]
|
||||
Reference in New Issue
Block a user