first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
},
}
)

View File

@@ -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`,
}),
]

View File

@@ -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
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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]

View File

@@ -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)
}

View File

@@ -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',
},
},
})

View File

@@ -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 `
}

View File

@@ -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,
})
}

View File

@@ -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()
}
}
}

View File

@@ -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,
},
])
)

View File

@@ -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,
]

View File

@@ -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')
})
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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))
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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%'
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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'
}
}

View File

@@ -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`)
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,17 @@
import { WidgetType } from '@codemirror/view'
export class TildeWidget extends WidgetType {
toDOM() {
const element = document.createElement('span')
element.textContent = '\xa0' // '&nbsp;' but not using innerHTML
return element
}
eq() {
return true
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -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,
]