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,36 @@
import { Command } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
import { emitShortcutEvent } from '@/features/source-editor/extensions/toolbar/utils/analytics'
export const cloneSelectionVertically =
(forward: boolean, cumulative: boolean, modifier: string): Command =>
view => {
const { main, ranges, mainIndex } = view.state.selection
const { anchor, head, goalColumn } = main
const start = EditorSelection.range(anchor, head, goalColumn)
const nextRange = view.moveVertically(start, forward)
let filteredRanges = [...ranges]
if (!cumulative && filteredRanges.length > 1) {
// remove the current main range
filteredRanges.splice(mainIndex, 1)
}
// prevent duplication when going in the opposite direction
filteredRanges = filteredRanges.filter(
item => item.from !== nextRange.from && item.to !== nextRange.to
)
const selection = EditorSelection.create(
filteredRanges.concat(nextRange),
filteredRanges.length
)
view.dispatch({ selection })
emitShortcutEvent(view, 'clone-selection-vertically', {
forward,
cumulative,
modifier,
})
return true
}

View File

@@ -0,0 +1,50 @@
import { EditorSelection } from '@codemirror/state'
import { getIndentUnit, indentString, indentUnit } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
export const indentMore = (view: EditorView) => {
if (view.state.readOnly) {
return false
}
view.dispatch(
view.state.changeByRange(range => {
const doc = view.state.doc
const changes = []
if (range.empty) {
// insert space(s) at the cursor
const line = doc.lineAt(range.from)
const unit = getIndentUnit(view.state)
const offset = range.from - line.from
const cols = unit - (offset % unit)
const insert = indentString(view.state, cols)
changes.push({ from: range.from, insert })
} else {
// indent selected lines
const insert = view.state.facet(indentUnit)
let previousLineNumber = -1
for (let pos = range.from; pos <= range.to; pos++) {
const line = doc.lineAt(pos)
if (previousLineNumber === line.number) {
continue
}
changes.push({ from: line.from, insert })
previousLineNumber = line.number
}
}
const changeSet = view.state.changes(changes)
return {
changes: changeSet,
range: EditorSelection.range(
changeSet.mapPos(range.anchor, 1),
changeSet.mapPos(range.head, 1)
),
}
})
)
return true
}

View File

@@ -0,0 +1,762 @@
import { EditorView } from '@codemirror/view'
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import {
ensureSyntaxTree,
foldedRanges,
foldEffect,
syntaxTree,
} from '@codemirror/language'
import { SyntaxNode } from '@lezer/common'
import { ancestorOfNodeWithType } from '../utils/tree-query'
import { lastAncestorAtEndPosition } from '../utils/tree-operations/ancestors'
import {
formattingCommandMap,
type FormattingCommand,
type FormattingNodeType,
} from '@/features/source-editor/utils/tree-operations/formatting'
export const wrapRanges =
(
prefix: string,
suffix: string,
wrapWholeLine = false,
selection?: (range: SelectionRange, view: EditorView) => SelectionRange
) =>
(view: EditorView): boolean => {
if (view.state.readOnly) {
return false
}
view.dispatch(
view.state.changeByRange(range => {
const insert = { prefix, suffix }
if (wrapWholeLine) {
const line = view.state.doc.lineAt(range.anchor)
if (range.empty) {
// expand range to cover the whole line
range = EditorSelection.range(line.from, line.to)
}
// add a newline at the start if needed
if (range.from !== line.from) {
insert.prefix = `\n${prefix}`
}
// add a newline at the end if needed
if (range.to !== line.to) {
insert.suffix = `${suffix}\n`
}
}
const content = view.state.sliceDoc(range.from, range.to)
// map through the prefix only
const changedRange = range.map(
view.state.changes([
{ from: range.from, insert: `${insert.prefix}` },
]),
1
)
return {
range: selection ? selection(changedRange, view) : changedRange,
// create a single change, including the content
changes: [
{
from: range.from,
to: range.to,
insert: `${insert.prefix}${content}${insert.suffix}`,
},
],
}
}),
{ scrollIntoView: true }
)
return true
}
export const changeCase =
(upper = true) =>
(view: EditorView) => {
if (view.state.readOnly) {
return false
}
view.dispatch(
view.state.changeByRange(range => {
// ignore empty ranges
if (range.empty) {
return { range }
}
const text = view.state.doc.sliceString(range.from, range.to)
return {
range,
changes: [
{
from: range.from,
to: range.to,
insert: upper ? text.toUpperCase() : text.toLowerCase(),
},
],
}
})
)
return true
}
export const duplicateSelection = (view: EditorView) => {
if (view.state.readOnly) {
return false
}
const foldedRangesInDocument = foldedRanges(view.state)
view.dispatch(
view.state.changeByRange(range => {
const folds: { offset: number; base: number; length: number }[] = []
if (range.empty) {
const line = view.state.doc.lineAt(range.from)
let lineStart = line.from
let lineEnd = line.to
// Calculate line start/end including folded ranges
//
// Note that at each iteration of the while loop, new folded ranges
// can be included. This happens when there are multiple folded ranges
// on a single editor line (but spanning multiple actual lines)
//
// For example, the following document:
// 1: \begin{document}
// 2: test
// 3: \end{document}\begin{document}
// 4: test
// 5: \end{document}
//
// Can be folded to:
// \begin{document}<...>\end{document}\begin{document}<...>\end{document}
//
// In this case, the first iterations of the while loop below will only
// include lines 3-5, since the overlapping folded range for line 5
// is only the fold on lines 3-5. Hence in the while loop, we expand the
// selection until we include all the ranges.
let changed
do {
changed = false
foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => {
const newLineStart = Math.min(
view.state.doc.lineAt(from).from,
lineStart
)
const newLineEnd = Math.max(view.state.doc.lineAt(to).to, lineEnd)
if (newLineStart !== lineStart || newLineEnd !== lineEnd) {
changed = true
lineStart = newLineStart
lineEnd = newLineEnd
}
})
} while (changed)
// Collect information needed to fold duplicated lines
foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => {
folds.push({
offset: from - lineStart,
base: lineEnd + view.state.lineBreak.length,
length: to - from,
})
})
// Duplicate the selected lines downwards
return {
range,
changes: [
{
from: lineEnd,
insert:
view.state.lineBreak + view.state.doc.slice(lineStart, lineEnd),
},
],
// Add a fold effect for each fold in the original line
effects: folds.map(fold =>
foldEffect.of({
from: fold.base + fold.offset,
to: fold.base + fold.offset + fold.length,
})
),
}
} else {
// Duplicate selected text at head of selection
let newSelectionRange = range
if (range.head > range.anchor) {
// Duplicating to the right, so we need to update the selected range
newSelectionRange = EditorSelection.range(
range.head,
range.head + (range.to - range.from)
)
}
return {
// The new range is the duplicated section, placed at the head of the
// original selection
range: newSelectionRange,
changes: [
{
from: range.head,
insert: view.state.doc.slice(range.from, range.to),
},
],
}
}
})
)
return true
}
const skipParentNodeTypes = [
'LongArg',
'TextArgument',
'OpenBrace',
'CloseBrace',
]
function getParentNode(
position: number | SyntaxNode,
state: EditorState,
assoc: 0 | 1 | -1 = 1
): SyntaxNode | undefined {
const tree = ensureSyntaxTree(state, 1000)
let node: SyntaxNode | undefined | null
if (typeof position === 'number') {
node = tree?.resolveInner(position, assoc)?.parent
const ancestorAtEndPos = lastAncestorAtEndPosition(node, position)
if (ancestorAtEndPos?.parent) {
node = ancestorAtEndPos.parent
}
} else {
node = position?.parent
}
while (node && skipParentNodeTypes.includes(node.type.name)) {
node = node.parent
}
return node || undefined
}
function wrapRangeInCommand(
state: EditorState,
range: SelectionRange,
command: string
) {
const content = state.sliceDoc(range.from, range.to)
const changes = state.changes([
{
from: range.from,
to: range.to,
insert: `${command}{${content}}`,
},
])
return {
changes,
range: moveRange(
range,
range.from + command.length + 1,
range.from + command.length + content.length + 1
),
}
}
function moveRange(range: SelectionRange, newFrom: number, newTo: number) {
const forwards = range.from === range.anchor
return forwards
? EditorSelection.range(newFrom, newTo)
: EditorSelection.range(newTo, newFrom)
}
function validateReplacement(expected: string, actual: string) {
if (expected !== actual) {
throw new Error('Replacement in toggleRange failed validation.')
}
}
function getWrappingAncestor(
node: SyntaxNode,
nodeType: FormattingNodeType
): SyntaxNode | null {
for (
let ancestor: SyntaxNode | null = node;
ancestor;
ancestor = ancestor.parent
) {
if (ancestor.type.is(nodeType)) {
return ancestor
}
if (formattingCommandMap[ancestor.type.name as FormattingCommand]) {
// We could multiple levels deep in bold/non-bold. So bail out in this case
return null
}
}
return null
}
function adjustRangeIfNeeded(
nodeType: FormattingNodeType,
range: SelectionRange,
state: EditorState
) {
// Try to adjust the selection, if it is either
// 1. \textbf<{test>}
// 2. \textbf{<test}>
// 3. \textbf<{test}>
// 4. <\textbf{test}>
// 4. \textbf<>{test}
const tree = syntaxTree(state)
if (tree.length < range.to) {
return range
}
const nodeLeft = tree.resolveInner(range.from, 1)
const nodeRight = tree.resolveInner(range.to, -1)
const parentLeft = getWrappingAncestor(nodeLeft, nodeType)
const parentRight = getWrappingAncestor(nodeRight, nodeType)
const parent = getParentNode(nodeLeft, state)
if (
parent?.type.is('$ToggleTextFormattingCommand') &&
spansWholeArgument(parent, range)
) {
return bubbleUpRange(
nodeType,
ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
range
)
}
if (!parentLeft) {
// We're not trying to unbold, so don't bother adjusting range
return bubbleUpRange(
nodeType,
ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
range
)
}
if (nodeLeft.type.is('$CtrlSeq') && range.from === range.to) {
const commandNode = nodeLeft.parent?.parent?.parent
if (!commandNode) {
return range
}
return EditorSelection.cursor(commandNode.from)
}
let { from, to } = range
if (nodeLeft.type.is('$CtrlSeq')) {
from = nodeLeft.to + 1
}
if (nodeLeft.type.is('OpenBrace')) {
from = nodeLeft.to
}
// We know that parentLeft is the $ToggleTextFormattingCommand, so now we check if we're
// to the right of the closing brace. (parent is TextArgument, grandparent is
// $ToggleTextFormattingCommand)
if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) {
to = nodeRight.from
}
return bubbleUpRange(nodeType, parentLeft, moveRange(range, from, to))
}
function spansWholeArgument(
commandNode: SyntaxNode | null,
range: SelectionRange
): boolean {
const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg')
return Boolean(
argument && argument.from === range.from && argument.to === range.to
)
}
function bubbleUpRange(
nodeType: string | number,
node: SyntaxNode | null,
range: SelectionRange
) {
let currentRange = range
for (
let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType(
node,
'$ToggleTextFormattingCommand'
);
spansWholeArgument(ancestorCommand, currentRange);
ancestorCommand = ancestorOfNodeWithType(
ancestorCommand.parent,
'$ToggleTextFormattingCommand'
)
) {
if (!ancestorCommand) {
break
}
currentRange = moveRange(
currentRange,
ancestorCommand.from,
ancestorCommand.to
)
if (ancestorCommand.type.is(nodeType)) {
const argumentNode = ancestorCommand
.getChild('TextArgument')
?.getChild('LongArg')
if (!argumentNode) {
return range
}
return moveRange(range, argumentNode.from, argumentNode.to)
}
}
return range
}
export function toggleRanges(command: FormattingCommand) {
const nodeType: FormattingNodeType = formattingCommandMap[command]
/* There are a number of situations we need to handle in this function.
* In the following examples, the selection range is marked within <>
* 1. If the parent node at start and end of selection is different -> do
* nothing & show error. Case 8 is an exception to this.
* -> For good and bad, this disallows \textbf{this <is} weird
* \textit{to> do}
* 2. If selection contains a BlankLine (i.e. two newlines in a row) -> do
* nothing & show error
* -> \textbf doesn't allow paragraph breaks).
* 3. If the selection is not within a \textbf -> wrap it in a \textbf
* 4. If selection is at the beginning of a \textbf -> shrink the \textbf
* command
* -> e.g. \textbf{<this is> a test} → <this is>\textbf{ a test}
* 5. Similarly for selection at end of \textbf command
* 6. If selection is in the middle of a \textbf command -> split the command
* into two
* -> e.g. \textbf{this <is a> test} → \textbf{this }<is a>\textbf{ test}
* 7. If the selection is a whole \textbf command → remove the wrapping
* command.
* 8. If the selection spans two \textbf's with the same parent then join the
* two
* -> e.g. \textbf{this <is} a \textbf{te>st} → \textbf{this <is a te>st}
* 9. If the selection spans the end of a \textbf, into the parent of the
* command, then extend the \textbf.
* -> e.g. \textbf{this <is} a test> → \textbf{this <is a test>}
* 10. Similarly for selections spanning the beginning of the selection
*/
return (view: EditorView): boolean => {
if (view.state.readOnly) {
return false
}
view.dispatch(
view.state.changeByRange(initialRange => {
const range = adjustRangeIfNeeded(nodeType, initialRange, view.state)
const content = view.state.sliceDoc(range.from, range.to)
const ancestorAtStartOfRange = getParentNode(
range.from,
view.state,
range.from === 0 ? 1 : -1
)
const ancestorAtEndOfRange = range.empty
? ancestorAtStartOfRange
: getParentNode(
range.to,
view.state,
range.to === view.state.doc.length ? -1 : 1
)
const tree = ensureSyntaxTree(view.state, 1000)
const nodeAtFrom = tree?.resolveInner(range.from, 1)
const nodeAtTo = tree?.resolveInner(range.to, -1)
const isSingleNodeSelected = nodeAtFrom === nodeAtTo
if (
!isSingleNodeSelected &&
ancestorAtStartOfRange !== ancestorAtEndOfRange
) {
// But handle the exception of case 8
const ancestorAtStartIsWrappingCommand =
ancestorAtStartOfRange?.type.is(nodeType)
const ancestorAtEndIsWrappingCommand =
ancestorAtEndOfRange && ancestorAtEndOfRange.type.is(nodeType)
if (
ancestorAtStartIsWrappingCommand &&
ancestorAtEndIsWrappingCommand &&
ancestorAtStartOfRange?.parent?.parent?.parent &&
ancestorAtEndOfRange?.parent?.parent?.parent
) {
// Test for case 8
const nextAncestorAtStartOfRange =
ancestorAtStartOfRange.parent.parent.parent
const nextAncestorAtEndOfRange =
ancestorAtEndOfRange.parent.parent.parent
if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) {
// Join the two ranges
const textBetweenRanges = view.state.sliceDoc(
ancestorAtStartOfRange.to,
ancestorAtEndOfRange.from
)
const ancestorStartArgumentNode =
ancestorAtStartOfRange.lastChild?.getChild('LongArg')
const ancestorEndArgumentNode =
ancestorAtEndOfRange.lastChild?.getChild('LongArg')
if (!ancestorStartArgumentNode || !ancestorEndArgumentNode) {
throw new Error("Can't find argument node")
}
const actualContent = view.state.sliceDoc(
ancestorAtStartOfRange.from,
ancestorAtEndOfRange.to
)
const firstCommandArgument = view.state.sliceDoc(
ancestorStartArgumentNode.from,
ancestorStartArgumentNode.to
)
const secondCommandArgument = view.state.sliceDoc(
ancestorEndArgumentNode.from,
ancestorEndArgumentNode.to
)
validateReplacement(
`${command}{${firstCommandArgument}}${textBetweenRanges}${command}{${secondCommandArgument}}`,
actualContent
)
const changes = view.state.changes([
{
from: ancestorAtStartOfRange.from,
to: ancestorAtEndOfRange.to,
insert: `${command}{${firstCommandArgument}${textBetweenRanges}${secondCommandArgument}}`,
},
])
return {
changes,
range: moveRange(
range,
range.from,
range.to - command.length - 1 - 1
),
}
}
}
if (
ancestorAtEndIsWrappingCommand &&
ancestorAtEndOfRange.parent?.parent?.parent ===
ancestorAtStartOfRange
) {
// Extend to the left. Case 10
const contentUpToCommand = view.state.sliceDoc(
range.from,
ancestorAtEndOfRange.from
)
const ancestorEndArgumentNode =
ancestorAtEndOfRange.lastChild?.getChild('LongArg')
if (!ancestorEndArgumentNode) {
throw new Error("Can't find argument node")
}
const commandContent = view.state.sliceDoc(
ancestorEndArgumentNode.from,
ancestorEndArgumentNode.to
)
const actualContent = view.state.sliceDoc(
range.from,
ancestorAtEndOfRange.to
)
validateReplacement(
`${contentUpToCommand}${command}{${commandContent}}`,
actualContent
)
const changes = view.state.changes([
{
from: range.from,
to: ancestorAtEndOfRange.to,
insert: `${command}{${contentUpToCommand}${commandContent}}`,
},
])
return {
changes,
range: moveRange(
range,
range.from + command.length + 1,
range.to
),
}
}
if (
ancestorAtStartIsWrappingCommand &&
ancestorAtStartOfRange &&
ancestorAtStartOfRange.parent?.parent?.parent ===
ancestorAtEndOfRange
) {
// Extend to the right. Case 9
const contentAfterCommand = view.state.sliceDoc(
ancestorAtStartOfRange.to,
range.to
)
const ancestorStartArgumentNode =
ancestorAtStartOfRange.lastChild?.getChild('LongArg')
if (!ancestorStartArgumentNode) {
throw new Error("Can't find argument node")
}
const commandContent = view.state.sliceDoc(
ancestorStartArgumentNode.from,
ancestorStartArgumentNode.to
)
const actualContent = view.state.sliceDoc(
ancestorAtStartOfRange.from,
range.to
)
validateReplacement(
`${command}{${commandContent}}${contentAfterCommand}`,
actualContent
)
const changes = view.state.changes([
{
from: ancestorAtStartOfRange.from,
to: range.to,
insert: `${command}{${commandContent}${contentAfterCommand}}`,
},
])
return {
changes,
range: moveRange(range, range.from, range.to - 1),
}
}
// Bail out in case 1
// TODO: signal error to the user
return { range }
}
const ancestor = ancestorAtStartOfRange
// Bail out in case 2
if (content.includes('\n\n')) {
// TODO: signal error to the user
return { range }
}
const isCursorBeforeAncestor =
range.empty &&
ancestor &&
range.from === ancestor.from &&
ancestor.type.is(nodeType)
// If we can't find an ancestor node, or if the parent is not an exsting
// \textbf, then we just wrap it in a range. Case 3.
if (isCursorBeforeAncestor || !ancestor?.type.is(nodeType)) {
return wrapRangeInCommand(view.state, range, command)
}
const argumentNode = ancestor.lastChild?.getChild('LongArg')
if (!argumentNode) {
throw new Error("Can't find argument node")
}
// We should trim from the beginning. Case 4
if (range.from === argumentNode.from && range.to !== argumentNode.to) {
const textAfterSelection = view.state.sliceDoc(
range.to,
argumentNode.to
)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(
`${command}{${content}${textAfterSelection}}`,
wholeCommand
)
const changes = view.state.changes([
{
from: ancestor.from,
to: ancestor.to,
insert: `${content}${command}{${textAfterSelection}}`,
},
])
return {
range: moveRange(
range,
range.from - command.length - 1,
range.to - command.length - 1
),
changes,
}
}
// We should trim from the end. Case 5
if (range.to === argumentNode.to && range.from !== argumentNode.from) {
const textBeforeSelection = view.state.sliceDoc(
ancestor.from,
range.from
)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(`${textBeforeSelection}${content}}`, wholeCommand)
const changes = view.state.changes([
{
from: ancestor.from,
to: ancestor.to,
insert: `${textBeforeSelection}}${content}`,
},
])
// We should shift selection forward by the } we insert
return {
range: moveRange(range, range.from + 1, range.to + 1),
changes,
}
}
// We should split the command in two. Case 6
if (range.from !== argumentNode.from && range.to !== argumentNode.to) {
const textBeforeSelection = view.state.sliceDoc(
ancestor.from,
range.from
)
const textAfterSelection = view.state.sliceDoc(range.to, ancestor.to)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(
`${textBeforeSelection}${content}${textAfterSelection}`,
wholeCommand
)
const changes = view.state.changes([
{
from: ancestor.from,
to: ancestor.to,
insert: `${textBeforeSelection}}${content}${command}{${textAfterSelection}`,
},
])
return {
range: moveRange(range, range.from + 1, range.to + 1),
changes,
}
}
// Remove the wrapping command. Case 7
if (spansWholeArgument(ancestor, range)) {
const argumentContent = view.state.sliceDoc(
argumentNode.from,
argumentNode.to
)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(`${command}{${content}}`, wholeCommand)
const changes = view.state.changes([
{ from: ancestor.from, to: ancestor.to, insert: argumentContent },
])
return {
range: moveRange(
range,
range.from - command.length - 1,
range.to - command.length - 1
),
changes,
}
}
// Shouldn't happen, but default to just wrapping the content
return wrapRangeInCommand(view.state, range, command)
}),
{ scrollIntoView: true }
)
return true
}
}

View File

@@ -0,0 +1,46 @@
import { EditorView } from '@codemirror/view'
import { EditorSelection, EditorState, StateCommand } from '@codemirror/state'
import { SearchQuery, StringQuery, selectWord } from '@codemirror/search'
export { selectNextOccurrence } from '@codemirror/search'
const findPrevOccurence = (state: EditorState, search: string) => {
const searchQuery = new SearchQuery({ search, literal: true })
const query = new StringQuery(searchQuery)
const { from, to } = state.selection.main
return query.prevMatch(state, from, to)
}
export const selectPrevOccurrence: StateCommand = ({ state, dispatch }) => {
const { ranges } = state.selection
if (ranges.some(range => range.from === range.to)) {
return selectWord({ state, dispatch })
}
const searchedText = state.sliceDoc(ranges[0].from, ranges[0].to)
if (
state.selection.ranges.some(
range => state.sliceDoc(range.from, range.to) !== searchedText
)
) {
return false
}
const range = findPrevOccurence(state, searchedText)
if (!range) {
return false
}
dispatch(
state.update({
selection: state.selection.addRange(
EditorSelection.range(range.from, range.to)
),
effects: EditorView.scrollIntoView(range.to),
})
)
return true
}

View File

@@ -0,0 +1,92 @@
import { memo, useEffect } from 'react'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from './codemirror-context'
import {
closeCommandTooltip,
commandTooltipState,
} from '../extensions/command-tooltip'
import ReactDOM from 'react-dom'
import { HrefTooltipContent } from './command-tooltip/href-tooltip'
import { UrlTooltipContent } from './command-tooltip/url-tooltip'
import { RefTooltipContent } from './command-tooltip/ref-tooltip'
import { IncludeTooltipContent } from './command-tooltip/include-tooltip'
import { InputTooltipContent } from './command-tooltip/input-tooltip'
import { getTooltip } from '@codemirror/view'
export const CodeMirrorCommandTooltip = memo(function CodeMirrorLinkTooltip() {
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const tooltipState = commandTooltipState(state)
const tooltipView = tooltipState && getTooltip(view, tooltipState.tooltip)
useEffect(() => {
if (!tooltipView) {
return
}
const controller = new AbortController()
tooltipView.dom.addEventListener(
'keydown',
(event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
// Escape to close the tooltip
event.preventDefault()
view.dispatch(closeCommandTooltip())
break
case 'Tab':
// Shift+Tab from the first element to return focus to the editor
if (
event.shiftKey &&
document.activeElement ===
tooltipView?.dom.querySelector('input,button')
) {
event.preventDefault()
view.focus()
}
break
default:
break
}
},
{ signal: controller.signal }
)
return () => controller.abort()
}, [tooltipView, view])
if (!tooltipView) {
return null
}
return ReactDOM.createPortal(
<CodeMirrorCommandTooltipContent command={tooltipState.command} />,
tooltipView.dom
)
})
const CodeMirrorCommandTooltipContent = memo<{
command: string
}>(function CodeMirrorCommandTooltipContent({ command }) {
switch (command) {
case 'HrefCommand':
return <HrefTooltipContent />
case 'UrlCommand':
return <UrlTooltipContent />
case 'Ref':
return <RefTooltipContent />
case 'Include':
return <IncludeTooltipContent />
case 'Input':
return <InputTooltipContent />
default:
return null
}
})

View File

@@ -0,0 +1,35 @@
import { createContext, useContext } from 'react'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
export const CodeMirrorStateContext = createContext<EditorState | undefined>(
undefined
)
export const CodeMirrorViewContext = createContext<EditorView | undefined>(
undefined
)
export const useCodeMirrorStateContext = (): EditorState => {
const context = useContext(CodeMirrorStateContext)
if (!context) {
throw new Error(
'useCodeMirrorStateContext is only available inside CodeMirrorStateContext.Provider'
)
}
return context
}
export const useCodeMirrorViewContext = (): EditorView => {
const context = useContext(CodeMirrorViewContext)
if (!context) {
throw new Error(
'useCodeMirrorViewContext is only available inside CodeMirrorViewContext.Provider'
)
}
return context
}

View File

@@ -0,0 +1,98 @@
import { ElementType, memo, useRef, useState } from 'react'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import CodeMirrorView from './codemirror-view'
import CodeMirrorSearch from './codemirror-search'
import { CodeMirrorToolbar } from './codemirror-toolbar'
import { CodemirrorOutline } from './codemirror-outline'
import { CodeMirrorCommandTooltip } from './codemirror-command-tooltip'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { FigureModal } from './figure-modal/figure-modal'
import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers'
import { ReviewPanelNew } from '@/features/review-panel-new/components/review-panel-new'
import ReviewTooltipMenu from '@/features/review-panel-new/components/review-tooltip-menu'
import {
CodeMirrorStateContext,
CodeMirrorViewContext,
} from './codemirror-context'
import MathPreviewTooltip from './math-preview-tooltip'
import { useToolbarMenuBarEditorCommands } from '@/features/ide-redesign/hooks/use-toolbar-menu-editor-commands'
// TODO: remove this when definitely no longer used
export * from './codemirror-context'
const sourceEditorComponents = importOverleafModules(
'sourceEditorComponents'
) as { import: { default: ElementType }; path: string }[]
const sourceEditorToolbarComponents = importOverleafModules(
'sourceEditorToolbarComponents'
) as { import: { default: ElementType }; path: string }[]
function CodeMirrorEditor() {
// create the initial state
const [state, setState] = useState(() => {
return EditorState.create()
})
const isMounted = useIsMounted()
// create the view using the initial state and intercept transactions
const viewRef = useRef<EditorView | null>(null)
if (viewRef.current === null) {
// @ts-ignore (disable EditContext-based editing until stable)
EditorView.EDIT_CONTEXT = false
const view = new EditorView({
state,
dispatchTransactions: trs => {
view.update(trs)
if (isMounted.current) {
setState(view.state)
}
},
})
viewRef.current = view
}
return (
<CodeMirrorStateContext.Provider value={state}>
<CodeMirrorViewContext.Provider value={viewRef.current}>
<CodeMirrorEditorComponents />
</CodeMirrorViewContext.Provider>
</CodeMirrorStateContext.Provider>
)
}
function CodeMirrorEditorComponents() {
useToolbarMenuBarEditorCommands()
return (
<ReviewPanelProviders>
<CodemirrorOutline />
<CodeMirrorView />
<FigureModal />
<CodeMirrorSearch />
<CodeMirrorToolbar />
{sourceEditorToolbarComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
<CodeMirrorCommandTooltip />
<MathPreviewTooltip />
<ReviewTooltipMenu />
<ReviewPanelNew />
{sourceEditorComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</ReviewPanelProviders>
)
}
export default memo(CodeMirrorEditor)

View File

@@ -0,0 +1,33 @@
import { useCodeMirrorStateContext } from './codemirror-context'
import React, { useEffect } from 'react'
import { documentOutline } from '../languages/latex/document-outline'
import { ProjectionStatus } from '../utils/tree-operations/projection'
import useDebounce from '../../../shared/hooks/use-debounce'
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
const { setFlatOutline } = useOutlineContext()
const state = useCodeMirrorStateContext()
const debouncedState = useDebounce(state, 100)
const outlineResult = debouncedState.field(documentOutline, false)
// when the outline projection changes, calculate the flat outline
useEffect(() => {
if (outlineResult && outlineResult.status !== ProjectionStatus.Pending) {
// We have a (potentially partial) outline.
setFlatOutline({
items: outlineResult.items.map(element => ({
level: element.level,
title: element.title,
line: element.line,
})),
partial: outlineResult?.status === ProjectionStatus.Partial,
})
} else {
setFlatOutline(undefined)
}
}, [outlineResult, setFlatOutline])
return null
})

View File

@@ -0,0 +1,584 @@
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from './codemirror-context'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { runScopeHandlers } from '@codemirror/view'
import {
closeSearchPanel,
setSearchQuery,
SearchQuery,
findPrevious,
findNext,
replaceNext,
replaceAll,
getSearchQuery,
SearchCursor,
} from '@codemirror/search'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import OLButtonGroup from '@/features/ui/components/ol/ol-button-group'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLCloseButton from '@/features/ui/components/ol/ol-close-button'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import { getStoredSelection, setStoredSelection } from '../extensions/search'
import { debounce } from 'lodash'
import { EditorSelection, EditorState } from '@codemirror/state'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
const MATCH_COUNT_DEBOUNCE_WAIT = 100 // the amount of ms to wait before counting matches
const MAX_MATCH_COUNT = 999 // the maximum number of matches to count
const MAX_MATCH_TIME = 100 // the maximum amount of ms allowed for counting matches
type ActiveSearchOption =
| 'caseSensitive'
| 'regexp'
| 'wholeWord'
| 'withinSelection'
| null
type MatchPositions = {
current: number | null
total: number
interrupted: boolean
}
const CodeMirrorSearchForm: FC = () => {
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const { setProjectSearchIsOpen } = useLayoutContext()
const { userSettings } = useUserSettingsContext()
const emacsKeybindingsActive = userSettings.mode === 'emacs'
const [activeSearchOption, setActiveSearchOption] =
useState<ActiveSearchOption>(null)
// Generate random ID for option buttons. This is necessary because the label
// for each checkbox is separated from it in the DOM so that the buttons can
// be outside the natural tab order
const idSuffix = useMemo(() => Math.random().toString(16).slice(2), [])
const caseSensitiveId = 'caseSensitive' + idSuffix
const regexpId = 'regexp' + idSuffix
const wholeWordId = 'wholeWord' + idSuffix
const withinSelectionId = 'withinSelection' + idSuffix
const { t } = useTranslation()
const [position, setPosition] = useState<MatchPositions | null>(null)
const formRef = useRef<HTMLFormElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const replaceRef = useRef<HTMLInputElement | null>(null)
const handleInputRef = useCallback(node => {
inputRef.current = node
// focus the search input when the panel opens
if (node) {
node.select()
node.focus()
}
}, [])
const handleReplaceRef = useCallback(node => {
replaceRef.current = node
}, [])
const handleSubmit = useCallback(event => {
event.preventDefault()
}, [])
useEffect(() => {
buildPosition(state, setPosition)
}, [state])
const handleChange = useCallback(() => {
if (formRef.current) {
const data = Object.fromEntries(new FormData(formRef.current))
const query = new SearchQuery({
search: data.search as string,
replace: data.replace as string,
caseSensitive: data.caseSensitive === 'on',
regexp: data.regexp === 'on',
literal: data.regexp !== 'on',
wholeWord: data.wholeWord === 'on',
scope: getStoredSelection(view.state)?.ranges,
})
view.dispatch({ effects: setSearchQuery.of(query) })
}
}, [view])
const handleWithinSelectionChange = useCallback(() => {
const storedSelection = getStoredSelection(state)
view.dispatch(setStoredSelection(storedSelection ? null : state.selection))
handleChange()
}, [handleChange, state, view])
const handleFormKeyDown = useCallback(
event => {
if (runScopeHandlers(view, event, 'search-panel')) {
event.preventDefault()
}
},
[view]
)
// Returns true if the event was handled, false otherwise
const handleEmacsNavigation = useCallback(
event => {
const emacsCtrlSeq =
emacsKeybindingsActive &&
event.ctrlKey &&
!event.altKey &&
!event.shiftKey
if (!emacsCtrlSeq) {
return false
}
switch (event.key) {
case 's': {
event.stopPropagation()
event.preventDefault()
findNext(view)
return true
}
case 'r': {
event.stopPropagation()
event.preventDefault()
findPrevious(view)
return true
}
case 'g': {
event.stopPropagation()
event.preventDefault()
closeSearchPanel(view)
document.dispatchEvent(new CustomEvent('cm:emacs-close-search-panel'))
return true
}
default: {
return false
}
}
},
[view, emacsKeybindingsActive]
)
const handleSearchKeyDown = useCallback(
event => {
switch (event.key) {
case 'Enter':
event.preventDefault()
if (emacsKeybindingsActive) {
closeSearchPanel(view)
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.to),
})
} else if (event.shiftKey) {
findPrevious(view)
} else {
findNext(view)
}
break
}
handleEmacsNavigation(event)
},
[view, handleEmacsNavigation, emacsKeybindingsActive]
)
const handleReplaceKeyDown = useCallback(
event => {
switch (event.key) {
case 'Enter':
event.preventDefault()
replaceNext(view)
sendSearchEvent('search-replace-click', {
searchType: 'document',
action: 'replace',
method: 'keyboard',
})
break
case 'Tab': {
if (event.shiftKey) {
event.preventDefault()
inputRef.current?.focus()
}
}
}
handleEmacsNavigation(event)
},
[view, handleEmacsNavigation]
)
const focusSearchBox = useCallback(() => {
inputRef.current?.focus()
}, [])
const query = useMemo(() => {
return getSearchQuery(state)
}, [state])
const openFullProjectSearch = useCallback(() => {
setProjectSearchIsOpen(true)
closeSearchPanel(view)
window.setTimeout(() => {
window.dispatchEvent(
new CustomEvent('editor:full-project-search', { detail: query })
)
}, 200)
}, [setProjectSearchIsOpen, query, view])
const showReplace = !state.readOnly
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<form
ref={formRef}
onSubmit={handleSubmit}
onKeyDown={handleFormKeyDown}
className="ol-cm-search-form"
role="search"
>
<div className="ol-cm-search-controls">
<span
className={classnames('ol-cm-search-input-group', {
'ol-cm-search-input-error':
query.regexp && isInvalidRegExp(query.search),
})}
>
<OLFormControl
ref={handleInputRef}
type="text"
name="search"
// IMPORTANT: CodeMirror uses this attribute to focus the input
// when the panel opens and when the panel is refocused
main-field="true"
placeholder={t('search_search_for')}
autoComplete="off"
value={query.search || ''}
onChange={handleChange}
onKeyDown={handleSearchKeyDown}
className="ol-cm-search-form-input"
size="sm"
aria-label={t('search_command_find')}
/>
<OLTooltip
id="search-match-case"
description={t('search_match_case')}
>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: query.caseSensitive,
focused: activeSearchOption === 'caseSensitive',
}
)}
htmlFor={caseSensitiveId}
aria-label={t('search_match_case')}
>
Aa
</label>
</OLTooltip>
<OLTooltip id="search-regexp" description={t('search_regexp')}>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: query.regexp,
focused: activeSearchOption === 'regexp',
}
)}
htmlFor={regexpId}
aria-label={t('search_regexp')}
>
[.*]
</label>
</OLTooltip>
<OLTooltip
id="search-whole-word"
description={t('search_whole_word')}
>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: query.wholeWord,
focused: activeSearchOption === 'wholeWord',
}
)}
htmlFor={wholeWordId}
aria-label={t('search_whole_word')}
>
W
</label>
</OLTooltip>
<OLTooltip
id="search-within-selection"
description={t('search_within_selection')}
>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: !!query.scope,
focused: activeSearchOption === 'withinSelection',
}
)}
htmlFor={withinSelectionId}
aria-label={t('search_within_selection')}
>
<MaterialIcon type="format_align_left" />
</label>
</OLTooltip>
</span>
{showReplace && (
<span className="ol-cm-search-input-group ol-cm-search-replace-input">
<OLFormControl
ref={handleReplaceRef}
type="text"
name="replace"
placeholder={t('search_replace_with')}
autoComplete="off"
value={query.replace || ''}
onChange={handleChange}
onKeyDown={handleReplaceKeyDown}
className="ol-cm-search-form-input"
size="sm"
aria-label={t('search_command_replace')}
/>
</span>
)}
<div className="ol-cm-search-hidden-inputs">
<input
id={caseSensitiveId}
name="caseSensitive"
type="checkbox"
autoComplete="off"
checked={query.caseSensitive}
onChange={handleChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('caseSensitive')}
onBlur={() => setActiveSearchOption(null)}
/>
<input
id={regexpId}
name="regexp"
type="checkbox"
autoComplete="off"
checked={query.regexp}
onChange={handleChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('regexp')}
onBlur={() => setActiveSearchOption(null)}
/>
<input
id={wholeWordId}
name="wholeWord"
type="checkbox"
autoComplete="off"
checked={query.wholeWord}
onChange={handleChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('wholeWord')}
onBlur={() => setActiveSearchOption(null)}
/>
<input
id={withinSelectionId}
name="withinSelection"
type="checkbox"
autoComplete="off"
checked={!!query.scope}
onChange={handleWithinSelectionChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('withinSelection')}
onBlur={() => setActiveSearchOption(null)}
/>
</div>
<div className="ol-cm-search-form-group ol-cm-search-next-previous">
<OLButtonGroup className="ol-cm-search-form-button-group">
<OLButton
variant="secondary"
size="sm"
onClick={() => findPrevious(view)}
>
<MaterialIcon
type="keyboard_arrow_up"
accessibilityLabel={t('search_previous')}
/>
</OLButton>
<OLButton
variant="secondary"
size="sm"
onClick={() => findNext(view)}
>
<MaterialIcon
type="keyboard_arrow_down"
accessibilityLabel={t('search_next')}
/>
</OLButton>
</OLButtonGroup>
{isSplitTestEnabled('full-project-search') ? (
<OLTooltip
id="open-full-project-search"
description={t('search_all_project_files')}
>
<OLButton
variant="secondary"
size="sm"
onClick={() => {
sendSearchEvent('search-open', {
searchType: 'full-project',
method: 'button',
location: 'search-form',
})
openFullProjectSearch()
}}
>
<MaterialIcon
type="manage_search"
accessibilityLabel={t('search_next')}
/>
</OLButton>
</OLTooltip>
) : null}
{position !== null && (
<div className="ol-cm-search-form-position">
{position.current === null ? '?' : position.current} {t('of')}{' '}
{position.total}
{position.interrupted && '+'}
</div>
)}
</div>
{showReplace && (
<div className="ol-cm-search-form-group ol-cm-search-replace-buttons">
<OLButton
variant="secondary"
size="sm"
onClick={() => {
sendSearchEvent('search-replace-click', {
searchType: 'document',
action: 'replace',
method: 'button',
})
replaceNext(view)
}}
>
{t('search_replace')}
</OLButton>
<OLButton
variant="secondary"
size="sm"
onClick={() => {
sendSearchEvent('search-replace-click', {
searchType: 'document',
action: 'replace-all',
method: 'button',
})
replaceAll(view)
}}
>
{t('search_replace_all')}
</OLButton>
</div>
)}
</div>
<div className="ol-cm-search-form-close">
<OLTooltip id="search-close" description={<>{t('close')} (Esc)</>}>
<OLCloseButton onClick={() => closeSearchPanel(view)} />
</OLTooltip>
</div>
</form>
)
}
function isInvalidRegExp(source: string) {
try {
RegExp(source)
return false
} catch {
return true
}
}
export default CodeMirrorSearchForm
const buildPosition = debounce(
(
state: EditorState,
setPosition: (position: MatchPositions | null) => void
) => {
const { main } = state.selection
const query = getSearchQuery(state)
if (!query.valid) {
return setPosition(null)
}
const cursor = query.getCursor(state.doc) as SearchCursor
const startTime = Date.now()
let total = 0
let current = null
while (!cursor.next().done) {
total++
// if there are too many matches, bail out
if (total >= MAX_MATCH_COUNT) {
return setPosition({
current,
total,
interrupted: true,
})
}
const { from, to } = cursor.value
if (current === null && main.from === from && main.to === to) {
current = total
}
// if finding matches is taking too long, bail out
if (Date.now() - startTime > MAX_MATCH_TIME) {
return setPosition({
current,
total,
interrupted: true,
})
}
}
setPosition({
current: current ?? 0,
total,
interrupted: false,
})
},
MATCH_COUNT_DEBOUNCE_WAIT
)

View File

@@ -0,0 +1,19 @@
import { createPortal } from 'react-dom'
import CodeMirrorSearchForm from './codemirror-search-form'
import { useCodeMirrorViewContext } from './codemirror-context'
import { getPanel } from '@codemirror/view'
import { createSearchPanel } from '@codemirror/search'
function CodeMirrorSearch() {
const view = useCodeMirrorViewContext()
const panel = getPanel(view, createSearchPanel)
if (!panel) {
return null
}
return createPortal(<CodeMirrorSearchForm />, panel.dom)
}
export default CodeMirrorSearch

View File

@@ -0,0 +1,199 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from './codemirror-context'
import { useResizeObserver } from '@/shared/hooks/use-resize-observer'
import { ToolbarItems } from './toolbar/toolbar-items'
import { ToolbarOverflow } from './toolbar/overflow'
import useDropdown from '../../../shared/hooks/use-dropdown'
import { getPanel } from '@codemirror/view'
import { createToolbarPanel } from '../extensions/toolbar/toolbar-panel'
import EditorSwitch from './editor-switch'
import SwitchToPDFButton from './switch-to-pdf-button'
import { DetacherSynctexControl } from '../../pdf-preview/components/detach-synctex-control'
import DetachCompileButtonWrapper from '../../pdf-preview/components/detach-compile-button-wrapper'
import { isVisual } from '../extensions/visual/visual'
import { language } from '@codemirror/language'
import { minimumListDepthForSelection } from '../utils/tree-operations/ancestors'
import { debugConsole } from '@/utils/debugging'
import { useTranslation } from 'react-i18next'
import { ToggleSearchButton } from '@/features/source-editor/components/toolbar/toggle-search-button'
import ReviewPanelHeader from '@/features/review-panel-new/components/review-panel-header'
import useReviewPanelLayout from '@/features/review-panel-new/hooks/use-review-panel-layout'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
import Breadcrumbs from '@/features/ide-redesign/components/breadcrumbs'
import classNames from 'classnames'
export const CodeMirrorToolbar = () => {
const view = useCodeMirrorViewContext()
const panel = getPanel(view, createToolbarPanel)
if (!panel) {
return null
}
return createPortal(<Toolbar />, panel.dom)
}
const Toolbar = memo(function Toolbar() {
const { t } = useTranslation()
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const [overflowed, setOverflowed] = useState(false)
const overflowedItemsRef = useRef<Set<string>>(new Set())
const languageName = state.facet(language)?.name
const visual = isVisual(view)
const listDepth = minimumListDepthForSelection(state)
const newEditor = useIsNewEditorEnabled()
const { showHeader: showReviewPanelHeader } = useReviewPanelLayout()
const {
open: overflowOpen,
onToggle: setOverflowOpen,
ref: overflowRef,
} = useDropdown()
const buildOverflow = useCallback(
(element: Element) => {
debugConsole.log('recalculating toolbar overflow')
setOverflowOpen(false)
setOverflowed(true)
overflowedItemsRef.current = new Set()
const buttonGroups = [
...element.querySelectorAll<HTMLDivElement>('[data-overflow]'),
].reverse()
// restore all the overflowed items
for (const buttonGroup of buttonGroups) {
buttonGroup.classList.remove('overflow-hidden')
}
// find all the available items
for (const buttonGroup of buttonGroups) {
if (element.scrollWidth <= element.clientWidth) {
break
}
// add this item to the overflow
overflowedItemsRef.current.add(buttonGroup.dataset.overflow!)
buttonGroup.classList.add('overflow-hidden')
}
setOverflowed(overflowedItemsRef.current.size > 0)
},
[setOverflowOpen]
)
// calculate overflow when the container resizes
const { elementRef, resizeRef } = useResizeObserver(buildOverflow)
// calculate overflow when `languageName` or `visual` change
useEffect(() => {
if (resizeRef.current) {
buildOverflow(resizeRef.current.element)
}
}, [buildOverflow, languageName, resizeRef, visual])
// calculate overflow when buttons change
const observerRef = useRef<MutationObserver | null>(null)
const handleButtons = useCallback(
node => {
if (!('MutationObserver' in window)) {
return
}
if (node) {
observerRef.current = new MutationObserver(() => {
if (resizeRef.current) {
buildOverflow(resizeRef.current.element)
}
})
observerRef.current.observe(node, { childList: true })
} else if (observerRef.current) {
observerRef.current.disconnect()
}
},
[buildOverflow, resizeRef]
)
// calculate overflow when active element changes to/from inside a table
const insideTable = document.activeElement?.closest(
'.table-generator-help-modal,.table-generator,.table-generator-width-modal'
)
useEffect(() => {
if (resizeRef.current) {
buildOverflow(resizeRef.current.element)
}
}, [buildOverflow, insideTable, resizeRef])
const showActions = !state.readOnly && !insideTable
return (
<>
{newEditor && showReviewPanelHeader && <ReviewPanelHeader />}
<div
id="ol-cm-toolbar-wrapper"
className={classNames('ol-cm-toolbar-wrapper', {
'ol-cm-toolbar-wrapper-indented': newEditor && showReviewPanelHeader,
})}
>
<div
role="toolbar"
aria-label={t('toolbar_editor')}
className="ol-cm-toolbar toolbar-editor"
ref={elementRef}
>
<EditorSwitch />
{showActions && (
<ToolbarItems
state={state}
languageName={languageName}
visual={visual}
listDepth={listDepth}
/>
)}
<div className="ol-cm-toolbar-button-group ol-cm-toolbar-stretch">
{showActions && (
<ToolbarOverflow
overflowed={overflowed}
overflowOpen={overflowOpen}
setOverflowOpen={setOverflowOpen}
overflowRef={overflowRef}
>
<ToolbarItems
state={state}
overflowed={overflowedItemsRef.current}
languageName={languageName}
visual={visual}
listDepth={listDepth}
/>
</ToolbarOverflow>
)}
</div>
<div
className="ol-cm-toolbar-button-group ol-cm-toolbar-end"
ref={handleButtons}
>
<ToggleSearchButton state={state} />
<SwitchToPDFButton />
<DetacherSynctexControl />
<DetachCompileButtonWrapper />
</div>
</div>
{newEditor && <Breadcrumbs />}
</div>
</>
)
})

View File

@@ -0,0 +1,38 @@
import { memo, useCallback, useEffect } from 'react'
import { useCodeMirrorViewContext } from './codemirror-context'
import useCodeMirrorScope from '../hooks/use-codemirror-scope'
import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
function CodeMirrorView() {
const view = useCodeMirrorViewContext()
const [, setView] = useScopeValueSetterOnly('editor.view')
// append the editor view dom to the container node when mounted
const containerRef = useCallback(
node => {
if (node) {
node.appendChild(view.dom)
}
},
[view]
)
// destroy the editor when unmounted
useEffect(() => {
return () => {
view.destroy()
}
}, [view])
// add the editor view to the scope value store, so it can be accessed by external extensions
useEffect(() => {
setView(view)
}, [setView, view])
useCodeMirrorScope(view)
return <div ref={containerRef} style={{ height: '100%' }} />
}
export default memo(CodeMirrorView)

View File

@@ -0,0 +1,190 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '../codemirror-context'
import {
closeCommandTooltip,
resolveCommandNode,
} from '../../extensions/command-tooltip'
import {
LiteralArgContent,
ShortArg,
ShortTextArgument,
UrlArgument,
} from '../../lezer-latex/latex.terms.mjs'
import { EditorState } from '@codemirror/state'
import { openURL } from '@/features/source-editor/utils/url'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export const HrefTooltipContent: FC = () => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const [url, setUrl] = useState<string>(() => readUrl(state) ?? '')
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement | null>(null)
// Update the URL if the argument value changes while not editing
// TODO: on input blur, update the input value with this URL or read from the syntax tree?
useEffect(() => {
if (inputRef.current) {
const controller = new AbortController()
// update the input URL when it changes in the doc
inputRef.current.addEventListener(
'value-update',
event => {
setUrl((event as CustomEvent<string>).detail)
},
{ signal: controller.signal }
)
// focus the URL input element when the tooltip opens, if the view is focused,
// there is content selected in the doc, and no URL has been entered
if (view.hasFocus && !view.state.selection.main.empty) {
const currentUrl = readUrl(view.state)
if (!currentUrl) {
inputRef.current.focus()
}
}
inputRef.current?.addEventListener(
'blur',
() => {
const currentUrl = readUrl(view.state)
if (currentUrl) {
setUrl(currentUrl)
}
},
{ signal: controller.signal }
)
return () => controller.abort()
}
}, [view])
const handleSubmit = useCallback(
event => {
event.preventDefault()
view.dispatch(closeCommandTooltip())
view.focus()
},
[view]
)
return (
<div className="ol-cm-command-tooltip-content">
<OLForm className="ol-cm-command-tooltip-form" onSubmit={handleSubmit}>
<OLFormGroup controlId="link-tooltip-url-input">
<OLFormLabel>URL</OLFormLabel>
<OLFormControl
type="url"
size="sm"
htmlSize={50}
placeholder="https://…"
value={url}
ref={(element: HTMLInputElement) => {
inputRef.current = element
}}
autoComplete="off"
onChange={event => {
const url = (event.target as HTMLInputElement).value
setUrl(url)
const spec = replaceUrl(state, url)
if (spec) {
view.dispatch(spec)
}
}}
disabled={state.readOnly}
/>
</OLFormGroup>
</OLForm>
<OLButton
variant="link"
type="button"
className="ol-cm-command-tooltip-link justify-content-start"
onClick={() => {
// TODO: unescape content
openURL(url)
}}
>
<MaterialIcon type="open_in_new" />
{t('open_link')}
</OLButton>
{!state.readOnly && (
<OLButton
variant="link"
type="button"
className="ol-cm-command-tooltip-link justify-content-start"
onClick={() => {
const spec = removeLink(state)
if (spec) {
view.dispatch(spec)
view.focus()
}
}}
>
<MaterialIcon type="link_off" />
{t('remove_link')}
</OLButton>
)}
</div>
)
}
const readUrl = (state: EditorState) => {
const commandNode = resolveCommandNode(state)
const argumentNode = commandNode
?.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (argumentNode) {
return state.sliceDoc(argumentNode.from, argumentNode.to)
}
}
const replaceUrl = (state: EditorState, url: string) => {
const commandNode = resolveCommandNode(state)
const argumentNode = commandNode
?.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (argumentNode) {
return {
changes: {
from: argumentNode.from,
to: argumentNode.to,
insert: url,
},
}
}
}
const removeLink = (state: EditorState) => {
const commandNode = resolveCommandNode(state)
const contentNode = commandNode
?.getChild(ShortTextArgument)
?.getChild(ShortArg)
if (commandNode && contentNode) {
const content = state.sliceDoc(contentNode.from, contentNode.to)
return {
changes: {
from: commandNode.from,
to: commandNode.to,
insert: content,
},
}
}
}

View File

@@ -0,0 +1,24 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useIncludedFile } from '@/features/source-editor/hooks/use-included-file'
import MaterialIcon from '@/shared/components/material-icon'
import OLButton from '@/features/ui/components/ol/ol-button'
export const IncludeTooltipContent: FC = () => {
const { t } = useTranslation()
const { openIncludedFile } = useIncludedFile('IncludeArgument')
return (
<div className="ol-cm-command-tooltip-content">
<OLButton
variant="link"
type="button"
className="ol-cm-command-tooltip-link"
onClick={openIncludedFile}
>
<MaterialIcon type="edit" />
{t('open_file')}
</OLButton>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useIncludedFile } from '@/features/source-editor/hooks/use-included-file'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export const InputTooltipContent: FC = () => {
const { t } = useTranslation()
const { openIncludedFile } = useIncludedFile('InputArgument')
return (
<div className="ol-cm-command-tooltip-content">
<OLButton
variant="link"
type="button"
className="ol-cm-command-tooltip-link"
onClick={openIncludedFile}
>
<MaterialIcon type="edit" />
{t('open_file')}
</OLButton>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '../codemirror-context'
import { resolveCommandNode } from '../../extensions/command-tooltip'
import {
LabelArgument,
RefArgument,
ShortArg,
ShortTextArgument,
} from '../../lezer-latex/latex.terms.mjs'
import { SyntaxNode } from '@lezer/common'
import { syntaxTree } from '@codemirror/language'
import {
EditorSelection,
EditorState,
TransactionSpec,
} from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export const RefTooltipContent: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
return (
<div className="ol-cm-command-tooltip-content">
<OLButton
variant="link"
type="button"
className="ol-cm-command-tooltip-link"
onClick={() => {
const target = readTarget(state)
if (target) {
const labelNode = findTargetLabel(state, target)
// TODO: handle label not found
if (labelNode) {
view.dispatch(selectNode(labelNode))
view.focus()
}
}
}}
>
<MaterialIcon type="link" />
{t('open_target')}
</OLButton>
</div>
)
}
const readTarget = (state: EditorState) => {
const commandNode = resolveCommandNode(state)
const argumentNode = commandNode
?.getChild(RefArgument)
?.getChild(ShortTextArgument)
?.getChild(ShortArg)
if (argumentNode) {
return state.sliceDoc(argumentNode.from, argumentNode.to)
}
}
const findTargetLabel = (state: EditorState, target: string) => {
let labelNode: SyntaxNode | undefined
syntaxTree(state).iterate({
enter(nodeRef) {
if (labelNode) {
return false
}
if (nodeRef.type.is(LabelArgument)) {
const argumentNode = nodeRef.node
.getChild('ShortTextArgument')
?.getChild('ShortArg')
if (argumentNode) {
const label = state.sliceDoc(argumentNode.from, argumentNode.to)
if (label === target) {
labelNode = argumentNode
}
}
}
},
})
return labelNode
}
const selectNode = (node: SyntaxNode): TransactionSpec => {
const selection = EditorSelection.range(node.from, node.to)
return {
selection,
effects: EditorView.scrollIntoView(selection, {
y: 'center',
}),
}
}

View File

@@ -0,0 +1,47 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useCodeMirrorStateContext } from '../codemirror-context'
import { resolveCommandNode } from '../../extensions/command-tooltip'
import {
LiteralArgContent,
UrlArgument,
} from '../../lezer-latex/latex.terms.mjs'
import { EditorState } from '@codemirror/state'
import { openURL } from '@/features/source-editor/utils/url'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export const UrlTooltipContent: FC = () => {
const { t } = useTranslation()
const state = useCodeMirrorStateContext()
return (
<div className="ol-cm-command-tooltip-content">
<OLButton
variant="link"
type="button"
className="ol-cm-command-tooltip-link"
onClick={() => {
const url = readUrl(state)
if (url) {
openURL(url)
}
}}
>
<MaterialIcon type="open_in_new" />
{t('open_link')}
</OLButton>
</div>
)
}
const readUrl = (state: EditorState) => {
const commandNode = resolveCommandNode(state)
const argumentNode = commandNode
?.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (argumentNode) {
return state.sliceDoc(argumentNode.from, argumentNode.to)
}
}

View File

@@ -0,0 +1,107 @@
import { ChangeEvent, FC, memo, useCallback } from 'react'
import useScopeValue from '@/shared/hooks/use-scope-value'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { sendMB } from '../../../infrastructure/event-tracking'
import { isValidTeXFile } from '../../../main/is-valid-tex-file'
import { useTranslation } from 'react-i18next'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
function EditorSwitch() {
const { t } = useTranslation()
const [visual, setVisual] = useScopeValue('editor.showVisual')
const { openDocName } = useEditorManagerContext()
const richTextAvailable = openDocName ? isValidTeXFile(openDocName) : false
const handleChange = useCallback(
event => {
const editorType = event.target.value
switch (editorType) {
case 'cm6':
setVisual(false)
break
case 'rich-text':
setVisual(true)
break
}
sendMB('editor-switch-change', { editorType })
},
[setVisual]
)
return (
<div
className="editor-toggle-switch"
aria-label={t('toolbar_code_visual_editor_switch')}
>
<fieldset className="toggle-switch">
<legend className="sr-only">Editor mode.</legend>
<input
type="radio"
name="editor"
value="cm6"
id="editor-switch-cm6"
className="toggle-switch-input"
checked={!richTextAvailable || !visual}
onChange={handleChange}
/>
<label htmlFor="editor-switch-cm6" className="toggle-switch-label">
<span>{t('code_editor')}</span>
</label>
<RichTextToggle
checked={richTextAvailable && visual}
disabled={!richTextAvailable}
handleChange={handleChange}
/>
</fieldset>
</div>
)
}
const RichTextToggle: FC<{
checked: boolean
disabled: boolean
handleChange: (event: ChangeEvent<HTMLInputElement>) => void
}> = ({ checked, disabled, handleChange }) => {
const { t } = useTranslation()
const toggle = (
<span>
<input
type="radio"
name="editor"
value="rich-text"
id="editor-switch-rich-text"
className="toggle-switch-input"
checked={checked}
onChange={handleChange}
disabled={disabled}
/>
<label htmlFor="editor-switch-rich-text" className="toggle-switch-label">
<span>{t('visual_editor')}</span>
</label>
</span>
)
if (disabled) {
return (
<OLTooltip
description={t('visual_editor_is_only_available_for_tex_files')}
id="rich-text-toggle-tooltip"
overlayProps={{ placement: 'bottom' }}
tooltipProps={{ className: 'tooltip-wide' }}
>
{toggle}
</OLTooltip>
)
}
return toggle
}
export default memo(EditorSwitch)

View File

@@ -0,0 +1,53 @@
import {
FigureModalSource,
useFigureModalContext,
} from './figure-modal-context'
import { FigureModalHelp } from './figure-modal-help'
import { FigureModalFigureOptions } from './figure-modal-options'
import { FigureModalSourcePicker } from './figure-modal-source-picker'
import { FigureModalEditFigureSource } from './file-sources/figure-modal-edit-figure-source'
import { FigureModalOtherProjectSource } from './file-sources/figure-modal-other-project-source'
import { FigureModalCurrentProjectSource } from './file-sources/figure-modal-project-source'
import { FigureModalUploadFileSource } from './file-sources/figure-modal-upload-source'
import { FigureModalUrlSource } from './file-sources/figure-modal-url-source'
import { useCallback } from 'react'
import OLNotification from '@/features/ui/components/ol/ol-notification'
const sourceModes = new Map([
[FigureModalSource.FILE_TREE, FigureModalCurrentProjectSource],
[FigureModalSource.FROM_URL, FigureModalUrlSource],
[FigureModalSource.OTHER_PROJECT, FigureModalOtherProjectSource],
[FigureModalSource.FILE_UPLOAD, FigureModalUploadFileSource],
[FigureModalSource.EDIT_FIGURE, FigureModalEditFigureSource],
])
export default function FigureModalBody() {
const { source, helpShown, sourcePickerShown, error, dispatch } =
useFigureModalContext()
const Body = sourceModes.get(source)
const onDismiss = useCallback(() => {
dispatch({ error: undefined })
}, [dispatch])
if (helpShown) {
return <FigureModalHelp />
}
if (sourcePickerShown) {
return <FigureModalSourcePicker />
}
if (!Body) {
return null
}
return (
<>
{error && (
<OLNotification type="error" onDismiss={onDismiss} content={error} />
)}
<Body />
<FigureModalFigureOptions />
</>
)
}

View File

@@ -0,0 +1,125 @@
import { FC, createContext, useContext, useReducer } from 'react'
import { PastedImageData } from '../../extensions/figure-modal'
/* eslint-disable no-unused-vars */
export enum FigureModalSource {
NONE,
FILE_UPLOAD,
FILE_TREE,
FROM_URL,
OTHER_PROJECT,
EDIT_FIGURE,
}
/* eslint-enable no-unused-vars */
type FigureModalState = {
source: FigureModalSource
helpShown: boolean
sourcePickerShown: boolean
getPath?: () => Promise<string>
width: number | undefined
includeCaption: boolean
includeLabel: boolean
error?: string
pastedImageData?: PastedImageData
selectedItemId?: string
}
type FigureModalStateUpdate = Partial<FigureModalState>
const FigureModalContext = createContext<
| (FigureModalState & {
dispatch: (update: FigureModalStateUpdate) => void
})
| undefined
>(undefined)
export const useFigureModalContext = () => {
const context = useContext(FigureModalContext)
if (!context) {
throw new Error(
'useFigureModalContext is only available inside FigureModalProvider'
)
}
return context
}
const reducer = (prev: FigureModalState, action: Partial<FigureModalState>) => {
if ('source' in action && prev.source === FigureModalSource.NONE) {
// Reset when showing modal
return {
...prev,
width: 0.5,
includeLabel: true,
includeCaption: true,
helpShown: false,
sourcePickerShown: false,
getPath: undefined,
error: undefined,
pastedImageData: undefined,
...action,
}
}
return { ...prev, ...action }
}
type FigureModalExistingFigureState = {
name: string | undefined
hasComplexGraphicsArgument?: boolean
}
type FigureModalExistingFigureStateUpdate =
Partial<FigureModalExistingFigureState>
const FigureModalExistingFigureContext = createContext<
| (FigureModalExistingFigureState & {
dispatch: (update: FigureModalExistingFigureStateUpdate) => void
})
| undefined
>(undefined)
export const FigureModalProvider: FC = ({ children }) => {
const [state, dispatch] = useReducer(reducer, {
source: FigureModalSource.NONE,
helpShown: false,
sourcePickerShown: false,
getPath: undefined,
includeLabel: true,
includeCaption: true,
width: 0.5,
})
const [existingFigureState, dispatchFigureState] = useReducer(
(
prev: FigureModalExistingFigureState,
action: FigureModalExistingFigureStateUpdate
) => ({ ...prev, ...action }),
{
name: undefined,
}
)
return (
<FigureModalContext.Provider value={{ ...state, dispatch }}>
<FigureModalExistingFigureContext.Provider
value={{ ...existingFigureState, dispatch: dispatchFigureState }}
>
{children}
</FigureModalExistingFigureContext.Provider>
</FigureModalContext.Provider>
)
}
export const useFigureModalExistingFigureContext = () => {
const context = useContext(FigureModalExistingFigureContext)
if (!context) {
throw new Error(
'useFigureModalExistingFigureContext is only available inside FigureModalProvider'
)
}
return context
}

View File

@@ -0,0 +1,106 @@
import {
FigureModalSource,
useFigureModalContext,
} from './figure-modal-context'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { sendMB } from '../../../../infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export const FigureModalFooter: FC<{
onInsert: () => void
onCancel: () => void
onDelete: () => void
}> = ({ onInsert, onCancel, onDelete }) => {
const { t } = useTranslation()
return (
<>
<HelpToggle />
<OLButton variant="secondary" onClick={onCancel}>
{t('cancel')}
</OLButton>
<FigureModalAction onInsert={onInsert} onDelete={onDelete} />
</>
)
}
const HelpToggle = () => {
const { t } = useTranslation()
const { helpShown, dispatch } = useFigureModalContext()
if (helpShown) {
return (
<OLButton
variant="link"
className="figure-modal-help-link me-auto"
onClick={() => dispatch({ helpShown: false })}
>
<span>
<MaterialIcon type="arrow_left_alt" className="align-text-bottom" />
</span>{' '}
{t('back')}
</OLButton>
)
}
return (
<OLButton
variant="link"
className="figure-modal-help-link me-auto"
onClick={() => dispatch({ helpShown: true })}
>
<span>
<MaterialIcon type="help" className="align-text-bottom" />
</span>{' '}
{t('help')}
</OLButton>
)
}
const FigureModalAction: FC<{
onInsert: () => void
onDelete: () => void
}> = ({ onInsert, onDelete }) => {
const { t } = useTranslation()
const { helpShown, getPath, source, sourcePickerShown } =
useFigureModalContext()
if (helpShown) {
return null
}
if (sourcePickerShown) {
return (
<OLButton variant="danger" onClick={onDelete}>
{t('delete_figure')}
</OLButton>
)
}
if (source === FigureModalSource.EDIT_FIGURE) {
return (
<OLButton
variant="primary"
onClick={() => {
onInsert()
sendMB('figure-modal-edit')
}}
>
{t('done')}
</OLButton>
)
}
return (
<OLButton
variant="primary"
disabled={getPath === undefined}
onClick={() => {
onInsert()
sendMB('figure-modal-insert')
}}
>
{t('insert_figure')}
</OLButton>
)
}

View File

@@ -0,0 +1,65 @@
import { FC } from 'react'
import { Trans, useTranslation } from 'react-i18next'
const LearnWikiLink: FC<{ article: string }> = ({ article, children }) => {
return <a href={`/learn/latex/${article}`}>{children}</a>
}
export const FigureModalHelp = () => {
const { t } = useTranslation()
return (
<>
<p>{t('this_tool_helps_you_insert_figures')}</p>
<b>{t('editing_captions')}</b>
<p>{t('when_you_tick_the_include_caption_box')}</p>
<b>{t('understanding_labels')}</b>
<p>
<Trans
i18nKey="labels_help_you_to_easily_reference_your_figures"
components={[
// eslint-disable-next-line react/jsx-key
<code />,
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Inserting_Images#Labels_and_cross-references" />,
]}
/>
</p>
<b>{t('customizing_figures')}</b>
<p>
<Trans
i18nKey="there_are_lots_of_options_to_edit_and_customize_your_figures"
components={[
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Inserting_Images" />,
]}
/>
</p>
<b>{t('changing_the_position_of_your_figure')}</b>
<p>
<Trans
i18nKey="latex_places_figures_according_to_a_special_algorithm"
components={[
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Positioning_images_and_tables" />,
]}
/>
</p>
<b>{t('dealing_with_errors')}</b>
<p>
<Trans
i18nKey="are_you_getting_an_undefined_control_sequence_error"
components={[
// eslint-disable-next-line react/jsx-key
<code />,
// eslint-disable-next-line react/jsx-key
<LearnWikiLink article="Inserting_Images" />,
]}
/>
</p>
</>
)
}

View File

@@ -0,0 +1,123 @@
import { FC } from 'react'
import {
useFigureModalContext,
useFigureModalExistingFigureContext,
} from './figure-modal-context'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import OLFormText from '@/features/ui/components/ol/ol-form-text'
import OLToggleButtonGroup from '@/features/ui/components/ol/ol-toggle-button-group'
import OLToggleButton from '@/features/ui/components/ol/ol-toggle-button'
import MaterialIcon from '@/shared/components/material-icon'
export const FigureModalFigureOptions: FC = () => {
const { t } = useTranslation()
const { includeCaption, includeLabel, dispatch, width } =
useFigureModalContext()
const { hasComplexGraphicsArgument } = useFigureModalExistingFigureContext()
return (
<>
<OLFormGroup>
<OLFormCheckbox
id="figure-modal-caption"
defaultChecked={includeCaption}
onChange={event => dispatch({ includeCaption: event.target.checked })}
label={t('include_caption')}
/>
</OLFormGroup>
<OLFormGroup>
<OLFormCheckbox
id="figure-modal-label"
data-cy="include-label-option"
defaultChecked={includeLabel}
onChange={event => dispatch({ includeLabel: event.target.checked })}
label={
<span className="figure-modal-label-content">
{t('include_label')}
<span aria-hidden="true">
<OLFormText>
{t(
'used_when_referring_to_the_figure_elsewhere_in_the_document'
)}
</OLFormText>
</span>
</span>
}
/>
</OLFormGroup>
<OLFormGroup className="mb-0">
<div className="figure-modal-switcher-input">
<div>
{t('image_width')}{' '}
{hasComplexGraphicsArgument ? (
<OLTooltip
id="figure-modal-image-width-warning-tooltip"
description={t('a_custom_size_has_been_used_in_the_latex_code')}
overlayProps={{ delay: 0, placement: 'top' }}
>
<span>
<MaterialIcon type="warning" className="align-text-bottom" />
</span>
</OLTooltip>
) : (
<OLTooltip
id="figure-modal-image-width-tooltip"
description={t(
'the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document'
)}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<span>
<MaterialIcon type="help" className="align-text-bottom" />
</span>
</OLTooltip>
)}
</div>
<OLToggleButtonGroup
type="radio"
name="figure-width"
onChange={value => dispatch({ width: parseFloat(value) })}
defaultValue={width === 1 ? '1.0' : width?.toString()}
aria-label={t('image_width')}
>
<OLToggleButton
variant="secondary"
id="width-25p"
disabled={hasComplexGraphicsArgument}
value="0.25"
>
{t('1_4_width')}
</OLToggleButton>
<OLToggleButton
variant="secondary"
id="width-50p"
disabled={hasComplexGraphicsArgument}
value="0.5"
>
{t('1_2_width')}
</OLToggleButton>
<OLToggleButton
variant="secondary"
id="width-75p"
disabled={hasComplexGraphicsArgument}
value="0.75"
>
{t('3_4_width')}
</OLToggleButton>
<OLToggleButton
variant="secondary"
id="width-100p"
disabled={hasComplexGraphicsArgument}
value="1.0"
>
{t('full_width')}
</OLToggleButton>
</OLToggleButtonGroup>
</div>
</OLFormGroup>
</>
)
}

View File

@@ -0,0 +1,73 @@
import { FC } from 'react'
import {
FigureModalSource,
useFigureModalContext,
} from './figure-modal-context'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import MaterialIcon from '@/shared/components/material-icon'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export const FigureModalSourcePicker: FC = () => {
const { t } = useTranslation()
const {
hasLinkedProjectFileFeature,
hasLinkedProjectOutputFileFeature,
hasLinkUrlFeature,
} = getMeta('ol-ExposedSettings')
const { write } = usePermissionsContext()
return (
<div className="figure-modal-source-button-grid">
{write && (
<FigureModalSourceButton
type={FigureModalSource.FILE_UPLOAD}
title={t('replace_from_computer')}
icon="upload"
/>
)}
<FigureModalSourceButton
type={FigureModalSource.FILE_TREE}
title={t('replace_from_project_files')}
icon="inbox"
/>
{write &&
(hasLinkedProjectFileFeature || hasLinkedProjectOutputFileFeature) && (
<FigureModalSourceButton
type={FigureModalSource.OTHER_PROJECT}
title={t('replace_from_another_project')}
icon="folder_open"
/>
)}
{write && hasLinkUrlFeature && (
<FigureModalSourceButton
type={FigureModalSource.FROM_URL}
title={t('replace_from_url')}
icon="public"
/>
)}
</div>
)
}
const FigureModalSourceButton: FC<{
type: FigureModalSource
title: string
icon: string
}> = ({ type, title, icon }) => {
const { dispatch } = useFigureModalContext()
return (
<button
type="button"
className="figure-modal-source-button"
onClick={() => {
dispatch({ source: type, sourcePickerShown: false, getPath: undefined })
}}
>
<MaterialIcon type={icon} className="figure-modal-source-button-icon" />
<span className="figure-modal-source-button-title">{title}</span>
<MaterialIcon type="chevron_right" />
</button>
)
}

View File

@@ -0,0 +1,306 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import {
FigureModalProvider,
FigureModalSource,
useFigureModalContext,
useFigureModalExistingFigureContext,
} from './figure-modal-context'
import { FigureModalFooter } from './figure-modal-footer'
import { lazy, memo, Suspense, useCallback, useEffect } from 'react'
import { useCodeMirrorViewContext } from '../codemirror-context'
import { ChangeSpec } from '@codemirror/state'
import { snippet } from '@codemirror/autocomplete'
import {
FigureData,
PastedImageData,
editFigureData,
editFigureDataEffect,
} from '../../extensions/figure-modal'
import { ensureEmptyLine } from '../../extensions/toolbar/commands'
import { useTranslation } from 'react-i18next'
import useEventListener from '../../../../shared/hooks/use-event-listener'
import { prepareLines } from '../../utils/prepare-lines'
import { FeedbackBadge } from '@/shared/components/feedback-badge'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
const FigureModalBody = lazy(() => import('./figure-modal-body'))
export const FigureModal = memo(function FigureModal() {
return (
<FigureModalProvider>
<FigureModalContent />
</FigureModalProvider>
)
})
const FigureModalContent = () => {
const { t } = useTranslation()
const getTitle = useCallback(
(state: FigureModalSource) => {
switch (state) {
case FigureModalSource.FILE_UPLOAD:
return t('upload_from_computer')
case FigureModalSource.FILE_TREE:
return t('insert_from_project_files')
case FigureModalSource.FROM_URL:
return t('insert_from_url')
case FigureModalSource.OTHER_PROJECT:
return t('insert_from_another_project')
case FigureModalSource.EDIT_FIGURE:
return t('edit_figure')
default:
return t('insert_image')
}
},
[t]
)
const {
source,
dispatch,
helpShown,
getPath,
width,
includeCaption,
includeLabel,
sourcePickerShown,
} = useFigureModalContext()
const listener = useCallback(
(event: Event) => {
const { detail } = event as CustomEvent<{
source: FigureModalSource
fileId?: string
filePath?: string
}>
dispatch({
source: detail.source,
selectedItemId: detail.fileId,
getPath: detail.filePath ? async () => detail.filePath! : undefined,
})
},
[dispatch]
)
useEffect(() => {
window.addEventListener('figure-modal:open', listener)
return () => {
window.removeEventListener('figure-modal:open', listener)
}
}, [listener])
const { dispatch: updateExistingFigure } =
useFigureModalExistingFigureContext()
const view = useCodeMirrorViewContext()
const hide = useCallback(() => {
dispatch({ source: FigureModalSource.NONE })
view.requestMeasure()
view.focus()
}, [dispatch, view])
useEventListener(
'figure-modal:open-modal',
useCallback(() => {
const figure = view.state.field<FigureData>(editFigureData, false)
if (!figure) {
return
}
updateExistingFigure({
name: figure.file.path,
// The empty string should *not* be a complex argument
hasComplexGraphicsArgument: Boolean(figure.unknownGraphicsArguments),
})
dispatch({
source: FigureModalSource.EDIT_FIGURE,
width: figure.width,
includeCaption: figure.caption !== null,
includeLabel: figure.label !== null,
})
}, [view, dispatch, updateExistingFigure])
)
useEventListener(
'figure-modal:paste-image',
useCallback(
(image: CustomEvent<PastedImageData>) => {
dispatch({
source: FigureModalSource.FILE_UPLOAD,
pastedImageData: image.detail,
})
},
[dispatch]
)
)
const insert = useCallback(async () => {
const figure = view.state.field<FigureData>(editFigureData, false)
if (!getPath) {
throw new Error('Cannot insert figure without a file path')
}
let path: string
try {
path = await getPath()
} catch (error) {
dispatch({ error: String(error) })
return
}
const labelCommand = includeLabel ? '\\label{fig:enter-label}' : ''
const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
if (figure) {
// Updating existing figure
const hadCaptionBefore = figure.caption !== null
const hadLabelBefore = figure.label !== null
const changes: ChangeSpec[] = []
if (!hadCaptionBefore && includeCaption) {
// We should insert a caption
changes.push({
from: figure.graphicsCommand.to,
insert: prepareLines(
['', captionCommand],
view.state,
figure.graphicsCommand.to
),
})
}
if (!hadLabelBefore && includeLabel) {
const from = figure.caption?.to ?? figure.graphicsCommand.to
// We should insert a label
changes.push({
from,
insert: prepareLines(['', labelCommand], view.state, from),
})
}
if (hadCaptionBefore && !includeCaption) {
// We should remove the caption
changes.push({
from: figure.caption!.from,
to: figure.caption!.to,
insert: '',
})
}
if (hadLabelBefore && !includeLabel) {
// We should remove th label
changes.push({
from: figure.label!.from,
to: figure.label!.to,
insert: '',
})
}
if (!figure.unknownGraphicsArguments && width) {
// We understood the arguments, and should update the width
if (figure.graphicsCommandArguments !== null) {
changes.push({
from: figure.graphicsCommandArguments.from,
to: figure.graphicsCommandArguments.to,
insert: `width=${width}\\linewidth`,
})
} else {
// Insert new args
changes.push({
from: figure.file.from - 1,
insert: `[width=${width}\\linewidth]`,
})
}
}
changes.push({ from: figure.file.from, to: figure.file.to, insert: path })
view.dispatch({
changes: view.state.changes(changes),
effects: editFigureDataEffect.of(null),
})
} else {
const { pos, suffix } = ensureEmptyLine(
view.state,
view.state.selection.main
)
const widthArgument =
width !== undefined ? `[width=${width}\\linewidth]` : ''
const caption = includeCaption ? `\n\t\\caption{\${Enter Caption}}` : ''
const label = includeLabel ? `\n\t\\label{\${fig:enter-label}}` : ''
snippet(
`\\begin{figure}
\t\\centering
\t\\includegraphics${widthArgument}{${path}}${caption}${label}
\\end{figure}${suffix}\${}`
)(
{ state: view.state, dispatch: view.dispatch },
{ label: 'figure' },
pos,
pos
)
}
hide()
}, [getPath, view, hide, includeCaption, includeLabel, width, dispatch])
const onDelete = useCallback(() => {
const figure = view.state.field<FigureData>(editFigureData, false)
if (!figure) {
dispatch({ error: "Couldn't remove figure" })
return
}
view.dispatch({
effects: editFigureDataEffect.of(null),
changes: view.state.changes({
from: figure.from,
to: figure.to,
insert: '',
}),
})
dispatch({ sourcePickerShown: false })
hide()
}, [view, hide, dispatch])
const onCancel = useCallback(() => {
dispatch({ sourcePickerShown: false })
view.dispatch({ effects: editFigureDataEffect.of(null) })
hide()
}, [hide, view, dispatch])
if (source === FigureModalSource.NONE) {
return null
}
return (
<OLModal onHide={hide} className="figure-modal" show>
<OLModalHeader closeButton>
<OLModalTitle>
{helpShown
? t('help')
: sourcePickerShown
? t('replace_figure')
: getTitle(source)}{' '}
<FeedbackBadge
id="figure-modal-feedback"
url="https://forms.gle/PfEtwceYBNQ32DF4A"
text="Please click to give feedback about editing figures."
/>
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
<FigureModalBody />
</Suspense>
</OLModalBody>
<OLModalFooter>
<FigureModalFooter
onInsert={insert}
onCancel={onCancel}
onDelete={onDelete}
/>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from 'react'
import { File, FileOrDirectory } from '../../utils/file'
import { useTranslation } from 'react-i18next'
import { useCurrentProjectFolders } from '@/features/source-editor/hooks/use-current-project-folders'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLNotification from '@/features/ui/components/ol/ol-notification'
type FileNameInputProps = Omit<
React.ComponentProps<typeof OLFormControl>,
'onFocus'
> & { targetFolder: File | null; label: string }
function findFile(
folder: { id: string; name: string },
project: FileOrDirectory
): FileOrDirectory | null {
if (project.id === folder.id) {
return project
}
if (project.type !== 'folder') {
return null
}
for (const child of project.children ?? []) {
const search = findFile(folder, child)
if (search) {
return search
}
}
return null
}
function hasOverlap(
name: string,
folder: { id: string; name: string },
project: FileOrDirectory
): boolean {
const directory = findFile(folder, project)
if (!directory) {
return false
}
for (const child of directory.children ?? []) {
if (child.name === name) {
return true
}
}
return false
}
export const FileNameInput = ({
id,
label,
targetFolder,
...props
}: FileNameInputProps) => {
const { t } = useTranslation()
const [overlap, setOverlap] = useState<boolean>(false)
const { rootFolder } = useCurrentProjectFolders()
const { value } = props
useEffect(() => {
if (value) {
setOverlap(
hasOverlap(String(value), targetFolder ?? rootFolder, rootFolder)
)
} else {
setOverlap(false)
}
}, [value, targetFolder, rootFolder])
const onFocus = useCallback((event: React.FocusEvent<HTMLInputElement>) => {
if (!event.target) {
return true
}
const fileName = event.target.value
const fileExtensionIndex = fileName.lastIndexOf('.')
if (fileExtensionIndex >= 0) {
event.target.setSelectionRange(0, fileExtensionIndex)
}
}, [])
return (
<>
<OLFormGroup controlId={id}>
<OLFormLabel>{label}</OLFormLabel>
<OLFormControl onFocus={onFocus} {...props} />
{overlap && (
<OLNotification
type="warning"
content={t(
'a_file_with_that_name_already_exists_and_will_be_overriden'
)}
className="mt-1 mb-0"
/>
)}
</OLFormGroup>
</>
)
}

View File

@@ -0,0 +1,85 @@
import { useCallback } from 'react'
import { FileNameInput } from './file-name-input'
import { File } from '../../utils/file'
import { Select } from '../../../../shared/components/select'
import { useCurrentProjectFolders } from '../../hooks/use-current-project-folders'
import { useTranslation } from 'react-i18next'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
export const FileRelocator = ({
name,
setName,
onNameChanged,
onFolderChanged,
setNameDirty,
folder,
setFolder,
nameDisabled,
}: {
nameDisabled: boolean
name: string
setName: (name: string) => void
onNameChanged: (name: string) => void
folder: File | null
onFolderChanged: (folder: File | null | undefined) => void
setFolder: (folder: File) => void
setNameDirty: (nameDirty: boolean) => void
}) => {
const { t } = useTranslation()
const { folders, rootFile } = useCurrentProjectFolders()
const nameChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNameDirty(true)
setName(e.target.value)
onNameChanged(e.target.value)
},
[setName, setNameDirty, onNameChanged]
)
const selectedFolderChanged = useCallback(
(item: File | null | undefined) => {
if (item) {
setFolder(item)
} else {
setFolder(rootFile)
}
onFolderChanged(item)
},
[setFolder, onFolderChanged, rootFile]
)
return (
<>
<FileNameInput
id="figure-modal-relocated-file-name"
type="text"
label={t('file_name_in_this_project_figure_modal')}
value={name}
disabled={nameDisabled}
placeholder="example.jpg"
onChange={nameChanged}
targetFolder={folder}
/>
<OLFormGroup>
<Select
items={folders || []}
itemToString={item => {
if (item?.path === '' && item?.name === 'rootFolder') {
return t('no_folder')
}
if (item) {
return `${item.path}${item.name}`
}
return t('no_folder')
}}
itemToSubtitle={item => item?.path ?? ''}
itemToKey={item => item.id}
defaultText={t('select_folder_from_project')}
label={t('folder_location')}
optionalLabel
onSelectedItemChanged={selectedFolderChanged}
/>
</OLFormGroup>
</>
)
}

View File

@@ -0,0 +1,31 @@
import { FC, useEffect } from 'react'
import { FileContainer, FileUploadStatus } from './figure-modal-upload-source'
import {
useFigureModalContext,
useFigureModalExistingFigureContext,
} from '../figure-modal-context'
import { useTranslation } from 'react-i18next'
export const FigureModalEditFigureSource: FC = () => {
const { t } = useTranslation()
const { dispatch } = useFigureModalContext()
const { name } = useFigureModalExistingFigureContext()
useEffect(() => {
if (name === undefined) {
dispatch({ getPath: undefined })
} else {
dispatch({ getPath: async () => name })
}
}, [name, dispatch])
return (
<FileContainer
name={name ?? t('unknown')}
status={FileUploadStatus.SUCCESS}
onDelete={() => {
dispatch({ sourcePickerShown: true })
}}
/>
)
}

View File

@@ -0,0 +1,282 @@
import { FC, useEffect, useMemo, useState } from 'react'
import { Select } from '../../../../../shared/components/select'
import { useFigureModalContext } from '../figure-modal-context'
import {
Project,
useUserProjects,
} from '../../../../file-tree/hooks/use-user-projects'
import {
Entity,
useProjectEntities,
} from '../../../../file-tree/hooks/use-project-entities'
import {
OutputEntity,
useProjectOutputFiles,
} from '../../../../file-tree/hooks/use-project-output-files'
import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
import { File, isImageEntity } from '../../../utils/file'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { useProjectContext } from '../../../../../shared/context/project-context'
import { FileRelocator } from '../file-relocator'
import { useTranslation } from 'react-i18next'
import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import getMeta from '@/utils/meta'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
function suggestName(path: string) {
const parts = path.split('/')
return parts[parts.length - 1]
}
export const FigureModalOtherProjectSource: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { dispatch } = useFigureModalContext()
const { _id: projectId } = useProjectContext()
const { loading: projectsLoading, data: projects, error } = useUserProjects()
const [selectedProject, setSelectedProject] = useState<null | Project>(null)
const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =
getMeta('ol-ExposedSettings')
const [usingOutputFiles, setUsingOutputFiles] = useState<boolean>(
!hasLinkedProjectFileFeature
)
const [nameDirty, setNameDirty] = useState<boolean>(false)
const [name, setName] = useState<string>('')
const [folder, setFolder] = useState<File | null>(null)
const { rootFile } = useCurrentProjectFolders()
const [file, setFile] = useState<OutputEntity | Entity | null>(null)
const FileSelector = usingOutputFiles
? SelectFromProjectOutputFiles
: SelectFromProject
useEffect(() => {
if (error) {
dispatch({ error })
}
}, [error, dispatch])
const updateDispatch: (args: {
newFolder?: File | null
newName?: string
newSelectedProject?: Project | null
newFile?: OutputEntity | Entity | null
}) => void = ({
newFolder = folder,
newName = name,
newSelectedProject = selectedProject,
newFile = file,
}) => {
const targetFolder = newFolder ?? rootFile
if (!newName || !newSelectedProject || !newFile) {
dispatch({ getPath: undefined })
return
}
let body:
| {
parent_folder_id: string
provider: 'project_file'
name: string
data: { source_project_id: string; source_entity_path: string }
}
| {
parent_folder_id: string
provider: 'project_output_file'
name: string
data: {
source_project_id: string
source_output_file_path: string
build_id?: string
clsiServerId?: string
}
} = {
provider: 'project_file',
parent_folder_id: targetFolder.id,
name: newName,
data: {
source_project_id: newSelectedProject._id,
source_entity_path: newFile.path,
},
}
if (usingOutputFiles) {
body = {
...body,
provider: 'project_output_file',
data: {
source_project_id: newSelectedProject._id,
source_output_file_path: newFile.path,
clsiServerId: (newFile as OutputEntity).clsiServerId,
build_id: (newFile as OutputEntity).build,
},
}
}
dispatch({
getPath: async () => {
const fileTreeUpdate = waitForFileTreeUpdate(view)
await postJSON(`/project/${projectId}/linked_file`, {
body,
})
await fileTreeUpdate.withTimeout(500)
return targetFolder.path === '' && targetFolder.name === 'rootFolder'
? `${newName}`
: `${targetFolder.path ? targetFolder.path + '/' : ''}${
targetFolder.name
}/${name}`
},
})
}
return (
<>
<OLFormGroup>
<Select
items={projects ?? []}
itemToString={project => (project ? project.name : '')}
itemToKey={item => item._id}
defaultText={t('select_a_project_figure_modal')}
label={t('project_figure_modal')}
disabled={projectsLoading}
onSelectedItemChanged={item => {
const suggestion = nameDirty ? name : ''
setName(suggestion)
setSelectedProject(item ?? null)
setFile(null)
updateDispatch({
newSelectedProject: item ?? null,
newFile: null,
newName: suggestion,
})
}}
/>
</OLFormGroup>
<OLFormGroup>
<FileSelector
projectId={selectedProject?._id}
onSelectedItemChange={item => {
const suggestion = nameDirty ? name : suggestName(item?.path ?? '')
setName(suggestion)
setFile(item ?? null)
updateDispatch({
newFile: item ?? null,
newName: suggestion,
})
}}
/>
{hasLinkedProjectFileFeature && hasLinkedProjectOutputFileFeature && (
<div>
or{' '}
<OLButton
variant="link"
onClick={() => setUsingOutputFiles(value => !value)}
className="p-0 select-from-files-btn"
>
{usingOutputFiles
? t('select_from_project_files')
: t('select_from_output_files')}
</OLButton>
</div>
)}
</OLFormGroup>
<FileRelocator
folder={folder}
name={name}
nameDisabled={!file && !nameDirty}
onFolderChanged={item => {
const newFolder = item ?? rootFile
updateDispatch({ newFolder })
}}
onNameChanged={name => updateDispatch({ newName: name })}
setFolder={setFolder}
setName={setName}
setNameDirty={setNameDirty}
/>
</>
)
}
const SelectFile = <T extends { path: string }>({
disabled,
files,
onSelectedItemChange,
defaultText,
label,
loading = false,
}: {
disabled: boolean
files?: T[] | null
defaultText?: string
label?: string
loading?: boolean
onSelectedItemChange?: (item: T | null | undefined) => any
}) => {
const { t } = useTranslation()
defaultText = defaultText ?? t('select_a_file_figure_modal')
label = label ?? t('image_file')
const imageFiles = useMemo(() => files?.filter(isImageEntity), [files])
const empty = loading || !imageFiles || imageFiles.length === 0
return (
<Select
loading={loading}
items={imageFiles ?? []}
itemToString={file => (file ? file.path.replace(/^\//, '') : '')}
itemToKey={file => file.path}
defaultText={
imageFiles?.length === 0 ? t('no_image_files_found') : defaultText
}
label={label}
disabled={disabled || empty}
onSelectedItemChanged={onSelectedItemChange}
/>
)
}
const SelectFromProject: FC<{
projectId?: string
onSelectedItemChange?: (item: Entity | null | undefined) => any
}> = ({ projectId, onSelectedItemChange }) => {
const { loading, data: entities, error } = useProjectEntities(projectId)
const { dispatch } = useFigureModalContext()
useEffect(() => {
if (error) {
dispatch({ error })
}
}, [error, dispatch])
return (
<SelectFile
key={projectId}
files={entities}
loading={loading}
disabled={!projectId}
onSelectedItemChange={onSelectedItemChange}
/>
)
}
const SelectFromProjectOutputFiles: FC<{
projectId?: string
onSelectedItemChange?: (item: OutputEntity | null | undefined) => any
}> = ({ projectId, onSelectedItemChange }) => {
const { t } = useTranslation()
const { loading, data: entities, error } = useProjectOutputFiles(projectId)
const { dispatch } = useFigureModalContext()
useEffect(() => {
if (error) {
dispatch({ error })
}
}, [error, dispatch])
return (
<SelectFile
label={t('output_file')}
defaultText={t('select_an_output_file_figure_modal')}
loading={loading}
files={entities}
disabled={!projectId}
onSelectedItemChange={onSelectedItemChange}
/>
)
}

View File

@@ -0,0 +1,44 @@
import { FC, useMemo } from 'react'
import { Select } from '../../../../../shared/components/select'
import { useFigureModalContext } from '../figure-modal-context'
import { filterFiles, isImageFile } from '../../../utils/file'
import { useTranslation } from 'react-i18next'
import { useCurrentProjectFolders } from '@/features/source-editor/hooks/use-current-project-folders'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
export const FigureModalCurrentProjectSource: FC = () => {
const { t } = useTranslation()
const { rootFolder } = useCurrentProjectFolders()
const files = useMemo(
() => filterFiles(rootFolder)?.filter(isImageFile),
[rootFolder]
)
const { dispatch, selectedItemId } = useFigureModalContext()
const noFiles = files?.length === 0
return (
<OLFormGroup>
<Select
items={files || []}
itemToString={file => (file ? file.name : '')}
itemToSubtitle={item => item?.path ?? ''}
itemToKey={item => item.id}
defaultItem={
files && selectedItemId
? files.find(item => item.id === selectedItemId)
: undefined
}
defaultText={
noFiles
? t('no_image_files_found')
: t('select_image_from_project_files')
}
label="Image file"
onSelectedItemChanged={item => {
dispatch({
getPath: item ? async () => `${item.path}${item.name}` : undefined,
})
}}
/>
</OLFormGroup>
)
}

View File

@@ -0,0 +1,346 @@
import { FC, useCallback, useEffect, useState } from 'react'
import { useFigureModalContext } from '../figure-modal-context'
import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
import { File } from '../../../utils/file'
import { Dashboard } from '@uppy/react'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
import { Uppy, type UppyFile } from '@uppy/core'
import XHRUpload from '@uppy/xhr-upload'
import { refreshProjectMetadata } from '../../../../file-tree/util/api'
import { useProjectContext } from '../../../../../shared/context/project-context'
import classNames from 'classnames'
import { FileRelocator } from '../file-relocator'
import { useTranslation } from 'react-i18next'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
import getMeta from '@/utils/meta'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import OLSpinner from '@/features/ui/components/ol/ol-spinner'
/* eslint-disable no-unused-vars */
export enum FileUploadStatus {
ERROR,
SUCCESS,
NOT_ATTEMPTED,
UPLOADING,
}
/* eslint-enable no-unused-vars */
export const FigureModalUploadFileSource: FC = () => {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { dispatch, pastedImageData } = useFigureModalContext()
const { _id: projectId } = useProjectContext()
const { rootFile } = useCurrentProjectFolders()
const [folder, setFolder] = useState<File | null>(null)
const [nameDirty, setNameDirty] = useState<boolean>(false)
// Files are immutable, so this will point to a (possibly) old version of the file
const [file, setFile] = useState<UppyFile | null>(null)
const [name, setName] = useState<string>('')
const [uploading, setUploading] = useState<boolean>(false)
const [uploadError, setUploadError] = useState<any>(null)
const [uppy] = useState(() =>
new Uppy({
allowMultipleUploadBatches: false,
restrictions: {
maxNumberOfFiles: 1,
maxFileSize: getMeta('ol-ExposedSettings').maxUploadSize,
allowedFileTypes: ['image/*', '.pdf'],
},
autoProceed: false,
})
// use the basic XHR uploader
.use(XHRUpload, {
endpoint: `/project/${projectId}/upload?folder_id=${rootFile.id}`,
headers: {
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
},
// limit: maxConnections || 1,
limit: 1,
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
})
)
const dispatchUploadAction = useCallback(
(name?: string, file?: UppyFile | null, folder?: File | null) => {
if (!name || !file) {
dispatch({ getPath: undefined })
return
}
dispatch({
getPath: async () => {
const fileTreeUpdate = waitForFileTreeUpdate(view)
const uploadResult = await uppy.upload()
await fileTreeUpdate.withTimeout(500)
if (!uploadResult.successful) {
throw new Error('Upload failed')
}
const uploadFolder = folder ?? rootFile
return uploadFolder.path === '' && uploadFolder.name === 'rootFolder'
? `${name}`
: `${uploadFolder.path ? uploadFolder.path + '/' : ''}${
uploadFolder.name
}/${name}`
},
})
},
[dispatch, rootFile, uppy, view]
)
useEffect(() => {
// broadcast doc metadata after each successful upload
const onUploadSuccess = (_file: UppyFile | undefined, response: any) => {
setUploading(false)
if (response.body.entity_type === 'doc') {
window.setTimeout(() => {
refreshProjectMetadata(projectId, response.body.entity_id)
}, 250)
}
}
const onFileAdded = (file: UppyFile) => {
const newName = nameDirty ? name : file.name
setName(newName)
setFile(file)
dispatchUploadAction(newName, file, folder)
}
const onFileRemoved = () => {
if (!nameDirty) {
setName('')
}
setFile(null)
dispatchUploadAction(undefined, null, folder)
}
const onUpload = () => {
// Set endpoint dynamically https://github.com/transloadit/uppy/issues/1790#issuecomment-581402293
setUploadError(null)
uppy.getFiles().forEach(file => {
uppy.setFileState(file.id, {
// HACK: There seems to be no other way of renaming the underlying file object
data: new globalThis.File([file.data], name),
meta: {
...file.meta,
name,
},
name,
xhrUpload: {
...(file as any).xhrUpload,
endpoint: `/project/${projectId}/upload?folder_id=${
(folder ?? rootFile).id
}`,
},
})
})
setUploading(true)
}
// handle upload errors
const onError = (
_file: UppyFile | undefined,
error: any,
response: any
) => {
setUploading(false)
setUploadError(error)
switch (response?.status) {
case 429:
dispatch({
error: 'Unable to process your file. Please try again later.',
})
break
case 403:
dispatch({ error: 'Your session has expired' })
break
default:
dispatch({
error: response?.body?.error ?? 'An unknown error occured',
})
break
}
}
uppy
.on('file-added', onFileAdded)
.on('file-removed', onFileRemoved)
.on('upload-success', onUploadSuccess)
.on('upload', onUpload)
.on('upload-error', onError)
return () => {
uppy
.off('file-added', onFileAdded)
.off('file-removed', onFileRemoved)
.off('upload-success', onUploadSuccess)
.off('upload', onUpload)
.off('upload-error', onError)
}
}, [
uppy,
folder,
rootFile,
name,
nameDirty,
dispatchUploadAction,
projectId,
file,
dispatch,
])
useEffect(() => {
if (pastedImageData) {
uppy.addFile(pastedImageData)
}
}, [uppy, pastedImageData])
return (
<>
<OLFormGroup>
<div className="figure-modal-upload">
{file ? (
<FileContainer
name={file.name}
size={file.size}
status={
uploading
? FileUploadStatus.UPLOADING
: uploadError
? FileUploadStatus.ERROR
: FileUploadStatus.NOT_ATTEMPTED
}
onDelete={() => {
uppy.removeFile(file.id)
setFile(null)
const newName = nameDirty ? name : ''
setName(newName)
dispatchUploadAction(newName, null, folder)
}}
/>
) : (
<Dashboard
uppy={uppy}
showProgressDetails
height={120}
width="100%"
showLinkToFileUploadResult={false}
proudlyDisplayPoweredByUppy={false}
showSelectedFiles={false}
hideUploadButton
locale={{
strings: {
// Text to show on the droppable area.
// `%{browseFiles}` is replaced with a link that opens the system file selection dialog.
dropPasteFiles: `${t(
'drag_here_paste_an_image_or'
)} %{browseFiles}`,
// Used as the label for the link that opens the system file selection dialog.
browseFiles: t('select_from_your_computer'),
},
}}
/>
)}
</div>
</OLFormGroup>
<FileRelocator
folder={folder}
name={name}
nameDisabled={!file && !nameDirty}
onFolderChanged={item =>
dispatchUploadAction(name, file, item ?? rootFile)
}
onNameChanged={name => dispatchUploadAction(name, file, folder)}
setFolder={setFolder}
setName={setName}
setNameDirty={setNameDirty}
/>
</>
)
}
export const FileContainer: FC<{
name: string
size?: number
status: FileUploadStatus
onDelete?: () => any
}> = ({ name, size, status, onDelete }) => {
const { t } = useTranslation()
let icon = ''
switch (status) {
case FileUploadStatus.ERROR:
icon = 'cancel'
break
case FileUploadStatus.SUCCESS:
icon = 'check_circle'
break
case FileUploadStatus.NOT_ATTEMPTED:
icon = 'imagesmode'
break
}
return (
<div className="file-container">
<div className="file-container-file">
<span
className={classNames({
'text-success': status === FileUploadStatus.SUCCESS,
'text-danger': status === FileUploadStatus.ERROR,
})}
>
{status === FileUploadStatus.UPLOADING ? (
<OLSpinner size="sm" />
) : (
<MaterialIcon type={icon} className="align-text-bottom" />
)}
</span>
<div className="file-info">
<span className="file-name" aria-label={t('file_name_figure_modal')}>
{name}
</span>
{size !== undefined && <FileSize size={size} />}
</div>
<OLButton
variant="link"
className="p-0 text-decoration-none"
aria-label={t('remove_or_replace_figure')}
onClick={() => onDelete && onDelete()}
>
<MaterialIcon type="cancel" />
</OLButton>
</div>
</div>
)
}
const FileSize: FC<{ size: number; className?: string }> = ({
size,
className,
}) => {
const { t } = useTranslation()
const BYTE_UNITS: [string, number][] = [
['B', 1],
['KB', 1e3],
['MB', 1e6],
['GB', 1e9],
['TB', 1e12],
['PB', 1e15],
]
const labelIndex = Math.min(
Math.floor(Math.log10(size) / 3),
BYTE_UNITS.length - 1
)
const [label, bytesPerUnit] = BYTE_UNITS[labelIndex]
const sizeInUnits = Math.round(size / bytesPerUnit)
return (
<small aria-label={t('file_size')} className={className}>
{sizeInUnits} {label}
</small>
)
}

View File

@@ -0,0 +1,109 @@
import { FC, useState } from 'react'
import { useFigureModalContext } from '../figure-modal-context'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { useProjectContext } from '../../../../../shared/context/project-context'
import { File } from '../../../utils/file'
import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
import { FileRelocator } from '../file-relocator'
import { useTranslation } from 'react-i18next'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import { EditorView } from '@codemirror/view'
import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
function generateLinkedFileFetcher(
projectId: string,
url: string,
name: string,
folder: File,
view: EditorView
) {
return async () => {
const fileTreeUpdate = waitForFileTreeUpdate(view)
await postJSON(`/project/${projectId}/linked_file`, {
body: {
parent_folder_id: folder.id,
provider: 'url',
name,
data: {
url,
},
},
})
await fileTreeUpdate.withTimeout(500)
return folder.path === '' && folder.name === 'rootFolder'
? `${name}`
: `${folder.path ? folder.path + '/' : ''}${folder.name}/${name}`
}
}
export const FigureModalUrlSource: FC = () => {
const view = useCodeMirrorViewContext()
const { t } = useTranslation()
const [url, setUrl] = useState<string>('')
const [nameDirty, setNameDirty] = useState<boolean>(false)
const [name, setName] = useState<string>('')
const { _id: projectId } = useProjectContext()
const { rootFile } = useCurrentProjectFolders()
const [folder, setFolder] = useState<File>(rootFile)
const { dispatch, getPath } = useFigureModalContext()
// TODO: Find another way to do this
const ensureButtonActivation = (
newUrl: string,
newName: string,
folder: File | null | undefined
) => {
if (newUrl && newName) {
dispatch({
getPath: generateLinkedFileFetcher(
projectId,
newUrl,
newName,
folder ?? rootFile,
view
),
})
} else if (getPath) {
dispatch({ getPath: undefined })
}
}
return (
<>
<OLFormGroup controlId="figure-modal-url-url">
<OLFormLabel>{t('image_url')}</OLFormLabel>
<OLFormControl
type="text"
placeholder={t('enter_image_url')}
value={url}
onChange={e => {
setUrl(e.target.value)
let newName = name
if (!nameDirty) {
// TODO: Improve this
const parts = e.target.value.split('/')
newName = parts[parts.length - 1] ?? ''
setName(newName)
}
ensureButtonActivation(e.target.value, newName, folder)
}}
/>
</OLFormGroup>
<FileRelocator
folder={folder}
name={name}
nameDisabled={url.length === 0}
onFolderChanged={folder => ensureButtonActivation(url, name, folder)}
onNameChanged={name => ensureButtonActivation(url, name, folder)}
setFolder={setFolder}
setName={setName}
setNameDirty={setNameDirty}
/>
</>
)
}

View File

@@ -0,0 +1,156 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import MaterialIcon from '@/shared/components/material-icon'
import useEventListener from '@/shared/hooks/use-event-listener'
import { FC, useCallback, useLayoutEffect, useRef, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from './codemirror-context'
import { mathPreviewStateField } from '../extensions/math-preview'
import { getTooltip } from '@codemirror/view'
import ReactDOM from 'react-dom'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
const MathPreviewTooltipContainer: FC = () => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const mathPreviewState = state.field(mathPreviewStateField, false)
if (!mathPreviewState) {
return null
}
const { tooltip, mathContent } = mathPreviewState
if (!tooltip || !mathContent) {
return null
}
const tooltipView = getTooltip(view, tooltip)
if (!tooltipView) {
return null
}
return ReactDOM.createPortal(
<MathPreviewTooltip mathContent={mathContent} />,
tooltipView.dom
)
}
const MathPreviewTooltip: FC<{ mathContent: HTMLDivElement }> = ({
mathContent,
}) => {
const { t } = useTranslation()
const [showDisableModal, setShowDisableModal] = useState(false)
const { setMathPreview } = useProjectSettingsContext()
const openDisableModal = useCallback(() => setShowDisableModal(true), [])
const closeDisableModal = useCallback(() => setShowDisableModal(false), [])
const onHide = useCallback(() => {
window.dispatchEvent(new Event('editor:hideMathTooltip'))
}, [])
const mathRef = useRef<HTMLSpanElement>(null)
const keyDownListener = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
onHide()
}
},
[onHide]
)
useEventListener('keydown', keyDownListener)
useLayoutEffect(() => {
if (mathRef.current) {
mathRef.current.replaceChildren(mathContent)
}
}, [mathContent])
return (
<>
<div className="ol-cm-math-tooltip">
<span ref={mathRef} />
<Dropdown align="end">
<DropdownToggle
id="some-id"
className="math-tooltip-options-toggle"
variant="secondary"
size="sm"
>
<MaterialIcon
type="more_vert"
accessibilityLabel={t('more_options')}
/>
</DropdownToggle>
<DropdownMenu flip={false}>
<OLDropdownMenuItem
onClick={onHide}
description={t('temporarily_hides_the_preview')}
trailingIcon={
<span className="math-tooltip-options-keyboard-shortcut">
Esc
</span>
}
>
{t('hide')}
</OLDropdownMenuItem>
<OLDropdownMenuItem
onClick={openDisableModal}
description={t('permanently_disables_the_preview')}
>
{t('disable')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
</div>
{showDisableModal && (
<OLModal show onHide={closeDisableModal}>
<OLModalHeader>
<OLModalTitle>{t('disable_equation_preview')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t('disable_equation_preview_confirm')}
<br />
<Trans
i18nKey="disable_equation_preview_enable"
components={{ b: <strong /> }}
/>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={closeDisableModal}>
{t('cancel')}
</OLButton>
<OLButton variant="danger" onClick={() => setMathPreview(false)}>
{t('disable')}
</OLButton>
</OLModalFooter>
</OLModal>
)}
</>
)
}
export default MathPreviewTooltipContainer

View File

@@ -0,0 +1,209 @@
import {
FC,
HTMLProps,
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import Icon from '../../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
import { EditorView } from '@codemirror/view'
import { PastedContent } from '../../extensions/visual/pasted-content'
import useEventListener from '../../../../shared/hooks/use-event-listener'
import { FeedbackBadge } from '@/shared/components/feedback-badge'
import { sendMB } from '@/infrastructure/event-tracking'
import MaterialIcon from '@/shared/components/material-icon'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import { isMac } from '@/shared/utils/os'
export const PastedContentMenu: FC<{
insertPastedContent: (
view: EditorView,
pastedContent: PastedContent,
formatted: boolean
) => void
pastedContent: PastedContent
view: EditorView
formatted: boolean
}> = ({ view, insertPastedContent, pastedContent, formatted }) => {
const [menuOpen, setMenuOpen] = useState(false)
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation()
// record whether the Shift key is currently down, for use in the `paste` event handler
const shiftRef = useRef(false)
useEventListener(
'keydown',
useCallback((event: KeyboardEvent) => {
shiftRef.current = event.shiftKey
}, [])
)
// track interaction events
const trackedEventsRef = useRef<Record<string, boolean>>({
'pasted-content-button-shown': false,
'pasted-content-button-click': false,
})
const trackEventOnce = useCallback((key: string) => {
if (!trackedEventsRef.current[key]) {
trackedEventsRef.current[key] = true
sendMB(key)
}
}, [])
useEffect(() => {
if (menuOpen) {
trackEventOnce('pasted-content-button-click')
} else {
trackEventOnce('pasted-content-button-shown')
}
}, [menuOpen, trackEventOnce])
useEffect(() => {
if (menuOpen) {
const abortController = new AbortController()
view.dom.addEventListener(
'paste',
event => {
event.preventDefault()
event.stopPropagation()
insertPastedContent(view, pastedContent, !shiftRef.current)
setMenuOpen(false)
},
{ signal: abortController.signal, capture: true }
)
return () => {
abortController.abort()
}
}
}, [view, menuOpen, pastedContent, insertPastedContent])
// TODO: keyboard navigation
return (
<>
<button
ref={toggleButtonRef}
type="button"
id="pasted-content-menu-button"
aria-haspopup="true"
aria-expanded={menuOpen}
aria-controls="pasted-content-menu"
aria-label={t('paste_options')}
className="ol-cm-pasted-content-menu-toggle"
tabIndex={0}
onMouseDown={event => event.preventDefault()}
onClick={() => setMenuOpen(isOpen => !isOpen)}
style={{ userSelect: 'none' }}
>
<MaterialIcon type="content_copy" />
<MaterialIcon type="expand_more" />
</button>
{menuOpen && (
<OLOverlay
show
onHide={() => setMenuOpen(false)}
transition={false}
container={view.scrollDOM}
containerPadding={0}
placement="bottom"
rootClose
target={toggleButtonRef?.current}
>
<OLPopover
id="popover-pasted-content-menu"
className="ol-cm-pasted-content-menu-popover"
>
<div
className="ol-cm-pasted-content-menu"
id="pasted-content-menu"
role="menu"
aria-labelledby="pasted-content-menu-button"
>
<MenuItem
onClick={() => {
insertPastedContent(view, pastedContent, true)
sendMB('pasted-content-menu-click', {
action: 'paste-with-formatting',
})
setMenuOpen(false)
}}
>
<span style={{ visibility: formatted ? 'visible' : 'hidden' }}>
<Icon type="check" fw />
</span>
<span className="ol-cm-pasted-content-menu-item-label">
{t('paste_with_formatting')}
</span>
<span className="ol-cm-pasted-content-menu-item-shortcut">
{isMac ? '⌘V' : 'Ctrl+V'}
</span>
</MenuItem>
<MenuItem
onClick={() => {
insertPastedContent(view, pastedContent, false)
sendMB('pasted-content-menu-click', {
action: 'paste-without-formatting',
})
setMenuOpen(false)
}}
>
<span style={{ visibility: formatted ? 'hidden' : 'visible' }}>
<Icon type="check" fw />
</span>
<span className="ol-cm-pasted-content-menu-item-label">
{t('paste_without_formatting')}
</span>
<span className="ol-cm-pasted-content-menu-item-shortcut">
{isMac ? '⇧⌘V' : 'Ctrl+Shift+V'}
</span>
</MenuItem>
<MenuItem
style={{ borderTop: '1px solid #eee' }}
onClick={() => {
window.open(
'https://docs.google.com/forms/d/e/1FAIpQLSc7WcHrwz9fnCkUP5hXyvkG3LkSYZiR3lVJWZ0o6uqNQYrV7Q/viewform',
'_blank'
)
sendMB('pasted-content-menu-click', {
action: 'give-feedback',
})
setMenuOpen(false)
}}
>
<FeedbackBadge
id="paste-html-feedback"
url="https://docs.google.com/forms/d/e/1FAIpQLSc7WcHrwz9fnCkUP5hXyvkG3LkSYZiR3lVJWZ0o6uqNQYrV7Q/viewform"
/>
<span className="ol-cm-pasted-content-menu-item-label">
{t('give_feedback')}
</span>
</MenuItem>
</div>
</OLPopover>
</OLOverlay>
)}
</>
)
}
const MenuItem = ({
children,
...buttonProps
}: PropsWithChildren<HTMLProps<HTMLButtonElement>>) => (
<button
{...buttonProps}
type="button"
role="menuitem"
className="ol-cm-pasted-content-menu-item"
>
{children}
</button>
)

View File

@@ -0,0 +1,19 @@
import { lazy, memo, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback'
const CodeMirrorEditor = lazy(
() =>
import(/* webpackChunkName: "codemirror-editor" */ './codemirror-editor')
)
function SourceEditor() {
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<CodeMirrorEditor />
</Suspense>
)
}
export default withErrorBoundary(memo(SourceEditor), ErrorBoundaryFallback)

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useLayoutContext } from '../../../shared/context/layout-context'
function SwitchToPDFButton() {
const { pdfLayout, setView, detachRole } = useLayoutContext()
const { t } = useTranslation()
if (detachRole) {
return null
}
if (pdfLayout === 'sideBySide') {
return null
}
function handleClick() {
setView('pdf')
}
return (
<OLButton variant="secondary" size="sm" onClick={handleClick}>
<MaterialIcon type="picture_as_pdf" />
{t('switch_to_pdf')}
</OLButton>
)
}
export default SwitchToPDFButton

View File

@@ -0,0 +1,6 @@
import { EditorView } from '@codemirror/view'
import { emitCommandEvent } from '../../extensions/toolbar/utils/analytics'
export function emitTableGeneratorEvent(view: EditorView, command: string) {
emitCommandEvent(view, 'codemirror-table-generator-event', command)
}

View File

@@ -0,0 +1,33 @@
import { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from 'react'
interface CellInputProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
value: string
}
export type CellInputRef = {
focus: (options?: FocusOptions) => void
}
export const CellInput = forwardRef<CellInputRef, CellInputProps>(
function CellInput({ value, ...props }: CellInputProps, ref) {
const inputRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => {
return {
focus(options) {
inputRef.current?.setSelectionRange(value.length, value.length)
inputRef.current?.focus(options)
},
}
})
useLayoutEffect(() => {
if (inputRef?.current) {
inputRef.current.style.height = '1px'
inputRef.current.style.height = `${inputRef.current.scrollHeight}px`
}
}, [value])
return <textarea {...props} value={value} ref={inputRef} />
}
)

View File

@@ -0,0 +1,246 @@
import { FC, MouseEventHandler, useCallback, useEffect, useRef } from 'react'
import { CellData, ColumnDefinition, RowData } from './tabular'
import classNames from 'classnames'
import {
TableSelection,
useSelectionContext,
} from './contexts/selection-context'
import { useEditingContext } from './contexts/editing-context'
import { loadMathJax } from '../../../mathjax/load-mathjax'
import { typesetNodeIntoElement } from '../../extensions/visual/utils/typeset-content'
import { parser } from '../../lezer-latex/latex.mjs'
import { useTableContext } from './contexts/table-context'
import { CellInput, CellInputRef } from './cell-input'
import { useCodeMirrorViewContext } from '../codemirror-context'
export const Cell: FC<{
cellData: CellData
columnSpecification: ColumnDefinition
rowIndex: number
columnIndex: number
row: RowData
}> = ({
cellData,
columnSpecification: columnSpecificationFromTabular,
rowIndex,
columnIndex,
row,
}) => {
const columnSpecification = cellData.multiColumn
? cellData.multiColumn.columns.specification[0]
: columnSpecificationFromTabular
const { selection, setSelection, dragging, setDragging } =
useSelectionContext()
const { table } = useTableContext()
const renderDiv = useRef<HTMLDivElement>(null)
const cellRef = useRef<HTMLTableCellElement>(null)
const {
cellData: editingCellData,
updateCellData: update,
startEditing,
commitCellData,
} = useEditingContext()
const inputRef = useRef<CellInputRef>(null)
const view = useCodeMirrorViewContext()
const editing =
editingCellData?.rowIndex === rowIndex &&
editingCellData?.cellIndex >= columnIndex &&
editingCellData?.cellIndex <
columnIndex + (cellData.multiColumn?.columnSpan ?? 1)
const onMouseDown: MouseEventHandler = useCallback(
event => {
if (editing) {
return
}
if (event.button !== 0) {
return
}
setDragging(true)
document.getSelection()?.empty()
setSelection(current => {
if (event.shiftKey && current) {
return new TableSelection(current.from, {
cell: columnIndex,
row: rowIndex,
}).explode(table)
}
return new TableSelection({ cell: columnIndex, row: rowIndex }).explode(
table
)
})
},
[setDragging, columnIndex, rowIndex, setSelection, table, editing]
)
const onMouseUp = useCallback(() => {
if (dragging) {
setDragging(false)
}
}, [setDragging, dragging])
const onMouseMove: MouseEventHandler = useCallback(
event => {
if (dragging) {
if (event.buttons !== 1) {
setDragging(false)
return
}
document.getSelection()?.empty()
if (
selection?.to.cell === columnIndex &&
selection?.to.row === rowIndex
) {
// Do nothing if selection has remained the same
return
}
event.stopPropagation()
setSelection(current => {
if (current) {
return new TableSelection(current.from, {
row: rowIndex,
cell: columnIndex,
}).explode(table)
} else {
return new TableSelection({
row: rowIndex,
cell: columnIndex,
}).explode(table)
}
})
}
},
[
dragging,
columnIndex,
rowIndex,
setSelection,
selection,
setDragging,
table,
]
)
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus()
}
}, [editing, cellData.content.length])
const filterInput = useCallback((input: string) => {
// TODO: Are there situations where we don't want to filter the input?
return input
.replaceAll(/(^&|[^\\]&)/g, match =>
match.length === 1 ? '\\&' : `${match[0]}\\&`
)
.replaceAll(/(^%|[^\\]%)/g, match =>
match.length === 1 ? '\\%' : `${match[0]}\\%`
)
.replaceAll('\\\\', '')
}, [])
const isFocused =
selection?.to.row === rowIndex &&
selection?.to.cell >= columnIndex &&
selection?.to.cell < columnIndex + (cellData.multiColumn?.columnSpan ?? 1)
useEffect(() => {
if (isFocused && !editing && cellRef.current) {
cellRef.current.focus({ preventScroll: true })
}
}, [isFocused, editing])
useEffect(() => {
const toDisplay = cellData.content.trim()
if (renderDiv.current && !editing) {
const tree = parser.parse(toDisplay)
const node = tree.topNode
renderDiv.current.innerText = ''
typesetNodeIntoElement(
node,
renderDiv.current,
toDisplay.substring.bind(toDisplay)
)
loadMathJax()
.then(async MathJax => {
const element = renderDiv.current
if (element) {
await MathJax.typesetPromise([element])
view.requestMeasure()
MathJax.typesetClear([element])
}
})
.catch(() => {})
}
}, [cellData.content, editing, view])
const onInput = useCallback(
e => {
update(filterInput(e.target.value))
},
[update, filterInput]
)
let body = <div ref={renderDiv} className="table-generator-cell-render" />
if (editing) {
body = (
<CellInput
className="table-generator-cell-input"
value={editingCellData.content}
onBlur={commitCellData}
onInput={onInput}
ref={inputRef}
/>
)
}
const inSelection = selection?.contains(
{
row: rowIndex,
cell: columnIndex,
},
table
)
const onDoubleClick = useCallback(() => {
if (!view.state.readOnly) {
startEditing(rowIndex, columnIndex, cellData.content)
}
}, [columnIndex, rowIndex, startEditing, cellData.content, view])
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<td
onDoubleClick={onDoubleClick}
tabIndex={row.cells.length * rowIndex + columnIndex + 1}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
colSpan={cellData.multiColumn?.columnSpan}
ref={cellRef}
className={classNames('table-generator-cell', {
'table-generator-cell-border-left': columnSpecification.borderLeft > 0,
'table-generator-cell-border-right':
columnSpecification.borderRight > 0,
'table-generator-row-border-top': row.borderTop > 0,
'table-generator-row-border-bottom': row.borderBottom > 0,
'alignment-left': columnSpecification.alignment === 'left',
'alignment-center': columnSpecification.alignment === 'center',
'alignment-right': columnSpecification.alignment === 'right',
'alignment-paragraph': columnSpecification.alignment === 'paragraph',
selected: inSelection,
'selection-edge-top': inSelection && selection?.bordersTop(rowIndex),
'selection-edge-bottom':
inSelection && selection?.bordersBottom(rowIndex),
'selection-edge-left':
inSelection && selection?.bordersLeft(rowIndex, columnIndex, table),
'selection-edge-right':
inSelection && selection?.bordersRight(rowIndex, columnIndex, table),
editing,
})}
>
{body}
</td>
)
}

View File

@@ -0,0 +1,59 @@
import MaterialIcon from '@/shared/components/material-icon'
import { WidthSelection } from './toolbar/column-width-modal/column-width'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { useSelectionContext } from './contexts/selection-context'
function roundIfNeeded(width: number) {
return width.toFixed(2).replace(/\.0+$/, '')
}
export const ColumnSizeIndicator = ({
size,
onClick,
}: {
size: WidthSelection
onClick: () => void
}) => {
const { t } = useTranslation()
const { selection } = useSelectionContext()
const { unit, width } = size
const formattedWidth = useMemo(() => {
if (unit === 'custom') {
return width
}
return `${roundIfNeeded(width)}${unit}`
}, [unit, width])
if (!selection) {
return null
}
return (
<OLTooltip
id="tooltip-column-width-button"
description={
unit === 'custom'
? t('column_width_is_custom_click_to_resize')
: t('column_width_is_x_click_to_resize', {
width: formattedWidth,
})
}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<button
className="btn table-generator-column-indicator-button"
onClick={onClick}
>
<MaterialIcon
type="format_text_wrap"
className="table-generator-column-indicator-icon"
/>
<span className="table-generator-column-indicator-label">
{formattedWidth}
</span>
</button>
</OLTooltip>
)
}

View File

@@ -0,0 +1,141 @@
import { FC, createContext, useCallback, useContext, useState } from 'react'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import { useTableContext } from './table-context'
import { TableSelection } from './selection-context'
import { debugConsole } from '@/utils/debugging'
type EditingContextData = {
rowIndex: number
cellIndex: number
content: string
dirty: boolean
}
const EditingContext = createContext<
| {
cellData: EditingContextData | null
updateCellData: (content: string) => void
cancelEditing: () => void
commitCellData: () => void
clearCells: (selection: TableSelection) => void
startEditing: (
rowIndex: number,
cellIndex: number,
initialContent?: string
) => void
}
| undefined
>(undefined)
export const useEditingContext = () => {
const context = useContext(EditingContext)
if (context === undefined) {
throw new Error(
'useEditingContext is only available inside EditingContext.Provider'
)
}
return context
}
export const EditingContextProvider: FC = ({ children }) => {
const { table } = useTableContext()
const [cellData, setCellData] = useState<EditingContextData | null>(null)
const [initialContent, setInitialContent] = useState<string | undefined>(
undefined
)
const view = useCodeMirrorViewContext()
const write = useCallback(
(rowIndex: number, cellIndex: number, content: string) => {
const { from, to } = table.getCell(rowIndex, cellIndex)
const currentText = view.state.sliceDoc(from, to)
if (currentText !== initialContent && initialContent !== undefined) {
// The cell has changed since we started editing, so we don't want to overwrite it
debugConsole.error(
'Cell has changed since editing started, not overwriting'
)
return
}
setInitialContent(undefined)
view.dispatch({
changes: { from, to, insert: content },
})
view.requestMeasure()
setCellData(null)
},
[view, table, initialContent]
)
const commitCellData = useCallback(() => {
if (!cellData) {
return
}
if (!cellData.dirty) {
setCellData(null)
setInitialContent(undefined)
return
}
const { rowIndex, cellIndex, content } = cellData
write(rowIndex, cellIndex, content)
setCellData(null)
}, [setCellData, cellData, write])
const cancelEditing = useCallback(() => {
setCellData(null)
}, [setCellData])
const startEditing = useCallback(
(rowIndex: number, cellIndex: number, initialContent = undefined) => {
if (cellData?.dirty) {
// We're already editing something else
commitCellData()
}
setInitialContent(initialContent)
const content = table.getCell(rowIndex, cellIndex).content.trim()
setCellData({
cellIndex,
rowIndex,
content,
dirty: false,
})
},
[setCellData, cellData, commitCellData, table]
)
const updateCellData = useCallback(
(content: string) => {
setCellData(prev => prev && { ...prev, content, dirty: true })
},
[setCellData]
)
const clearCells = useCallback(
(selection: TableSelection) => {
const changes: { from: number; to: number; insert: '' }[] = []
const { minX, minY, maxX, maxY } = selection.normalized()
for (let row = minY; row <= maxY; row++) {
for (let cell = minX; cell <= maxX; cell++) {
const { from, to } = table.getCell(row, cell)
changes.push({ from, to, insert: '' })
}
}
view.dispatch({ changes })
},
[view, table]
)
return (
<EditingContext.Provider
value={{
cellData,
updateCellData,
cancelEditing,
commitCellData,
startEditing,
clearCells,
}}
>
{children}
</EditingContext.Provider>
)
}

View File

@@ -0,0 +1,424 @@
import {
Dispatch,
FC,
SetStateAction,
createContext,
useContext,
useState,
} from 'react'
import { TableData } from '../tabular'
type TableCoordinate = {
readonly row: number
readonly cell: number
}
export class TableSelection {
public readonly from: TableCoordinate
public readonly to: TableCoordinate
constructor(from: TableCoordinate, to?: TableCoordinate) {
this.from = from
this.to = to ?? from
}
contains(anchor: TableCoordinate, table: TableData) {
const { minX, maxX, minY, maxY } = this.normalized()
const { from, to } = table.getCellBoundaries(anchor.row, anchor.cell)
return (
from >= minX && to <= maxX && anchor.row >= minY && anchor.row <= maxY
)
}
selectRow(row: number, extend: boolean, table: TableData) {
return new TableSelection(
{ row: extend ? this.from.row : row, cell: 0 },
{ row, cell: table.columns.length - 1 }
)
}
selectColumn(column: number, extend: boolean, table: TableData) {
return new TableSelection(
{ row: 0, cell: extend ? this.from.cell : column },
{ row: table.rows.length - 1, cell: column }
)
}
normalized() {
const minX = Math.min(this.from.cell, this.to.cell)
const maxX = Math.max(this.from.cell, this.to.cell)
const minY = Math.min(this.from.row, this.to.row)
const maxY = Math.max(this.from.row, this.to.row)
return { minX, maxX, minY, maxY }
}
bordersLeft(row: number, cell: number, table: TableData) {
const { minX } = this.normalized()
return minX === table.getCellBoundaries(row, cell).from
}
bordersRight(row: number, cell: number, table: TableData) {
const { maxX } = this.normalized()
return maxX === table.getCellBoundaries(row, cell).to
}
bordersTop(y: number) {
const { minY } = this.normalized()
return minY === y
}
bordersBottom(y: number) {
const { maxY } = this.normalized()
return maxY === y
}
toString() {
return `TableSelection(${this.from.row}, ${this.from.cell}) -> (${this.to.row}, ${this.to.cell})`
}
isRowSelected(row: number, table: TableData) {
const { minX, maxX, minY, maxY } = this.normalized()
return (
row >= minY &&
row <= maxY &&
minX === 0 &&
maxX === table.columns.length - 1
)
}
isAnyRowSelected(table: TableData) {
for (let i = 0; i < table.rows.length; ++i) {
if (this.isRowSelected(i, table)) {
return true
}
}
return false
}
isAnyColumnSelected(table: TableData) {
for (let i = 0; i < table.columns.length; ++i) {
if (this.isColumnSelected(i, table)) {
return true
}
}
return false
}
isColumnSelected(cell: number, table: TableData) {
const totalRows = table.rows.length
const { minX, maxX, minY, maxY } = this.normalized()
return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1
}
public eq(other: TableSelection) {
return (
this.from.row === other.from.row &&
this.from.cell === other.from.cell &&
this.to.row === other.to.row &&
this.to.cell === other.to.cell
)
}
public explode(table: TableData) {
const expandOnce = (current: TableSelection) => {
if (
current.to.row >= table.rows.length ||
current.to.cell >= table.columns.length
) {
throw new Error("Can't expand selection outside of table")
}
const { minX, maxX, minY, maxY } = current.normalized()
for (let row = minY; row <= maxY; ++row) {
const cellBoundariesMinX = table.getCellBoundaries(row, minX)
const cellBoundariesMaxX = table.getCellBoundaries(row, maxX)
if (cellBoundariesMinX.from < minX) {
if (current.from.cell === minX) {
return new TableSelection(
{ row: current.from.row, cell: cellBoundariesMinX.from },
{ row: current.to.row, cell: current.to.cell }
)
} else {
return new TableSelection(
{ row: current.from.row, cell: current.from.cell },
{ row: current.to.row, cell: cellBoundariesMinX.from }
)
}
} else if (cellBoundariesMaxX.to > maxX) {
if (current.to.cell === maxX) {
return new TableSelection(
{ row: current.from.row, cell: current.from.cell },
{ row: current.to.row, cell: cellBoundariesMaxX.to }
)
} else {
return new TableSelection(
{ row: current.from.row, cell: cellBoundariesMaxX.to },
{ row: current.to.row, cell: current.to.cell }
)
}
}
}
return current
}
let last: TableSelection = this
for (
let current = expandOnce(last);
!current.eq(last);
current = expandOnce(last)
) {
last = current
}
return last
}
moveRight(table: TableData) {
const totalColumns = table.columns.length
const newColumn = Math.min(
totalColumns - 1,
table.getCellBoundaries(this.to.row, this.to.cell).to + 1
)
return new TableSelection({
row: this.to.row,
cell: newColumn,
}).explode(table)
}
moveLeft(table: TableData) {
const row = this.to.row
const from = table.getCellBoundaries(row, this.to.cell).from
const newColumn = Math.max(0, from - 1)
return new TableSelection({ row: this.to.row, cell: newColumn }).explode(
table
)
}
moveUp(table: TableData) {
const newRow = Math.max(0, this.to.row - 1)
return new TableSelection({ row: newRow, cell: this.to.cell }).explode(
table
)
}
moveDown(table: TableData) {
const totalRows: number = table.rows.length
const newRow = Math.min(totalRows - 1, this.to.row + 1)
const cell = table.getCellBoundaries(this.to.row, this.to.cell).from
return new TableSelection({ row: newRow, cell }).explode(table)
}
moveNext(table: TableData) {
const totalRows = table.rows.length
const totalColumns = table.columns.length
const { row, cell } = this.to
const boundaries = table.getCellBoundaries(row, cell)
if (boundaries.to === totalColumns - 1 && row === totalRows - 1) {
return new TableSelection(this.to).explode(table)
}
if (boundaries.to === totalColumns - 1) {
return new TableSelection({ row: row + 1, cell: 0 }).explode(table)
}
return new TableSelection({ row, cell: boundaries.to + 1 }).explode(table)
}
movePrevious(table: TableData) {
const totalColumns = table.columns.length
const { row, cell } = this.to
const boundaries = table.getCellBoundaries(row, cell)
if (boundaries.from === 0 && this.to.row === 0) {
return new TableSelection(this.to).explode(table)
}
if (boundaries.from === 0) {
return new TableSelection({
row: this.to.row - 1,
cell: totalColumns - 1,
}).explode(table)
}
return new TableSelection({
row: this.to.row,
cell: boundaries.from - 1,
})
}
extendRight(table: TableData) {
const totalColumns = table.columns.length
const { minY, maxY } = this.normalized()
let newColumn = this.to.cell
for (let row = minY; row <= maxY; ++row) {
const boundary = table.getCellBoundaries(row, this.to.cell).to + 1
newColumn = Math.max(newColumn, boundary)
}
newColumn = Math.min(totalColumns - 1, newColumn)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: this.to.row, cell: newColumn }
).explode(table)
}
extendLeft(table: TableData) {
const { minY, maxY } = this.normalized()
let newColumn = this.to.cell
for (let row = minY; row <= maxY; ++row) {
const boundary = table.getCellBoundaries(row, this.to.cell).from - 1
newColumn = Math.min(newColumn, boundary)
}
newColumn = Math.max(0, newColumn)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: this.to.row, cell: newColumn }
).explode(table)
}
extendUp(table: TableData) {
const newRow = Math.max(0, this.to.row - 1)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: newRow, cell: this.to.cell }
).explode(table)
}
extendDown(table: TableData) {
const totalRows = table.rows.length
const newRow = Math.min(totalRows - 1, this.to.row + 1)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: newRow, cell: this.to.cell }
).explode(table)
}
spansEntireTable(table: TableData) {
const totalRows = table.rows.length
const totalColumns = table.columns.length
const { minX, maxX, minY, maxY } = this.normalized()
return (
minX === 0 &&
maxX === totalColumns - 1 &&
minY === 0 &&
maxY === totalRows - 1
)
}
isMergedCellSelected(table: TableData) {
if (this.from.row !== this.to.row) {
return false
}
const boundariesFrom = table.getCellBoundaries(
this.from.row,
this.from.cell
)
const boundariesTo = table.getCellBoundaries(this.to.row, this.to.cell)
if (boundariesFrom.from !== boundariesTo.from) {
// boundaries are for two different cells, so it's not a merged cell
return false
}
const cellData = table.getCell(this.from.row, boundariesFrom.from)
return cellData && Boolean(cellData.multiColumn)
}
isMergeableCells(table: TableData) {
const { minX, maxX, minY, maxY } = this.normalized()
if (minY !== maxY) {
return false
}
if (minX === maxX) {
return false
}
for (let cell = minX; cell <= maxX; ++cell) {
const cellData = table.getCell(this.from.row, cell)
if (cellData.multiColumn) {
return false
}
}
return true
}
isOnlyFixedWidthColumns(table: TableData) {
const { minX, maxX } = this.normalized()
for (let cell = minX; cell <= maxX; ++cell) {
if (!this.isColumnSelected(cell, table)) {
return false
}
if (!table.columns[cell].isParagraphColumn) {
return false
}
}
return true
}
isOnlyParagraphCells(table: TableData) {
const { minX, maxX } = this.normalized()
for (let cell = minX; cell <= maxX; ++cell) {
if (!table.columns[cell].isParagraphColumn) {
return false
}
}
return true
}
isOnlyNonFixedWidthColumns(table: TableData) {
const { minX, maxX } = this.normalized()
for (let cell = minX; cell <= maxX; ++cell) {
if (!this.isColumnSelected(cell, table)) {
return false
}
if (table.columns[cell].isParagraphColumn) {
return false
}
}
return true
}
width() {
const { minX, maxX } = this.normalized()
return maxX - minX + 1
}
height() {
const { minY, maxY } = this.normalized()
return maxY - minY + 1
}
maximumCellWidth(table: TableData) {
const { minX, maxX, minY, maxY } = this.normalized()
let maxWidth = 1
for (let row = minY; row <= maxY; ++row) {
const start = table.getCellIndex(row, minX)
const end = table.getCellIndex(row, maxX)
maxWidth = Math.max(maxWidth, end - start + 1)
}
return maxWidth
}
}
const SelectionContext = createContext<
| {
selection: TableSelection | null
setSelection: Dispatch<SetStateAction<TableSelection | null>>
dragging: boolean
setDragging: Dispatch<SetStateAction<boolean>>
}
| undefined
>(undefined)
export const useSelectionContext = () => {
const context = useContext(SelectionContext)
if (context === undefined) {
throw new Error(
'useSelectionContext is only available inside SelectionContext.Provider'
)
}
return context
}
export const SelectionContextProvider: FC = ({ children }) => {
const [selection, setSelection] = useState<TableSelection | null>(null)
const [dragging, setDragging] = useState(false)
return (
<SelectionContext.Provider
value={{ selection, setSelection, dragging, setDragging }}
>
{children}
</SelectionContext.Provider>
)
}

View File

@@ -0,0 +1,88 @@
import { FC, createContext, useContext } from 'react'
import { Positions, TableData, TableRenderingError } from '../tabular'
import {
CellPosition,
CellSeparator,
ParsedTableData,
RowPosition,
RowSeparator,
parseTableEnvironment,
} from '../utils'
import { EditorView } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
export type TableEnvironmentData = {
table: { from: number; to: number }
caption?: { from: number; to: number }
label?: { from: number; to: number }
}
const TableContext = createContext<
| {
table: TableData
cellPositions: CellPosition[][]
specification: { from: number; to: number }
rowPositions: RowPosition[]
rowSeparators: RowSeparator[]
cellSeparators: CellSeparator[][]
positions: Positions
tableEnvironment?: TableEnvironmentData
rows: number
columns: number
directTableChild?: boolean
}
| undefined
>(undefined)
export const TableProvider: FC<{
tableData: ParsedTableData
tableNode: SyntaxNode | null
tabularNode: SyntaxNode
view: EditorView
directTableChild?: boolean
}> = ({
tableData,
children,
tableNode,
tabularNode,
view,
directTableChild,
}) => {
try {
const positions: Positions = {
cells: tableData.cellPositions,
columnDeclarations: tableData.specification,
rowPositions: tableData.rowPositions,
tabular: { from: tabularNode.from, to: tabularNode.to },
}
const tableEnvironment = tableNode
? parseTableEnvironment(tableNode)
: undefined
return (
<TableContext.Provider
value={{
...tableData,
positions,
tableEnvironment,
rows: tableData.table.rows.length,
columns: tableData.table.columns.length,
directTableChild,
}}
>
{children}
</TableContext.Provider>
)
} catch {
return <TableRenderingError view={view} codePosition={tabularNode.from} />
}
}
export const useTableContext = () => {
const context = useContext(TableContext)
if (context === undefined) {
throw new Error('useTableContext must be used within a TableProvider')
}
return context
}

View File

@@ -0,0 +1,61 @@
import {
FC,
RefObject,
createContext,
useCallback,
useContext,
useRef,
useState,
} from 'react'
const TabularContext = createContext<
| {
ref: RefObject<HTMLDivElement>
showHelp: () => void
hideHelp: () => void
helpShown: boolean
columnWidthModalShown: boolean
openColumnWidthModal: () => void
closeColumnWidthModal: () => void
}
| undefined
>(undefined)
export const TabularProvider: FC = ({ children }) => {
const ref = useRef<HTMLDivElement>(null)
const [helpShown, setHelpShown] = useState(false)
const [columnWidthModalShown, setColumnWidthModalShown] = useState(false)
const showHelp = useCallback(() => setHelpShown(true), [])
const hideHelp = useCallback(() => setHelpShown(false), [])
const openColumnWidthModal = useCallback(
() => setColumnWidthModalShown(true),
[]
)
const closeColumnWidthModal = useCallback(
() => setColumnWidthModalShown(false),
[]
)
return (
<TabularContext.Provider
value={{
ref,
helpShown,
showHelp,
hideHelp,
columnWidthModalShown,
openColumnWidthModal,
closeColumnWidthModal,
}}
>
{children}
</TabularContext.Provider>
)
}
export const useTabularContext = () => {
const tabularContext = useContext(TabularContext)
if (!tabularContext) {
throw new Error('TabularContext must be used within TabularProvider')
}
return tabularContext
}

View File

@@ -0,0 +1,99 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTabularContext } from './contexts/tabular-context'
import { Trans, useTranslation } from 'react-i18next'
export const TableGeneratorHelpModal = () => {
const { helpShown, hideHelp } = useTabularContext()
const { t } = useTranslation()
if (!helpShown) return null
return (
<OLModal
show={helpShown}
onHide={hideHelp}
className="table-generator-help-modal"
>
<OLModalHeader closeButton>
<OLModalTitle>{t('help')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
<Trans
i18nKey="this_tool_helps_you_insert_simple_tables_into_your_project_without_writing_latex_code_give_feedback"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://forms.gle/ri3fzV1oQDAjmfmD7"
target="_blank"
rel="noopener noreferrer"
/>,
]}
/>
</p>
<b>{t('how_it_works')}</b>
<p>
<Trans
i18nKey="youll_get_best_results_in_visual_but_can_be_used_in_source"
// eslint-disable-next-line react/jsx-key
components={[<b />, <b />]}
/>
</p>
<b>{t('customizing_tables')}</b>
<p>
<Trans
i18nKey="if_you_need_to_customize_your_table_further_you_can"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://www.overleaf.com/learn/latex/Tables"
target="_blank"
rel="noopener"
/>,
]}
/>
</p>
<b>{t('changing_the_position_of_your_table')}</b>
<p>
<Trans
i18nKey="latex_places_tables_according_to_a_special_algorithm"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://www.overleaf.com/learn/latex/Positioning_images_and_tables"
target="_blank"
rel="noopener"
/>,
]}
/>
</p>
<b>{t('understanding_labels')}</b>
<p>
<Trans
i18nKey="labels_help_you_to_reference_your_tables"
components={[
// eslint-disable-next-line react/jsx-key
<code />,
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://www.overleaf.com/learn/latex/Inserting_Images#Labels_and_cross-references"
target="_blank"
rel="noopener"
/>,
]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={hideHelp}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,36 @@
import { FC } from 'react'
import { ColumnDefinition, RowData } from './tabular'
import { Cell } from './cell'
import { RowSelector } from './selectors'
const normalizedCellIndex = (row: RowData, index: number) => {
let normalized = 0
for (let i = 0; i < index; ++i) {
normalized += row.cells[i].multiColumn?.columnSpan ?? 1
}
return normalized
}
export const Row: FC<{
rowIndex: number
row: RowData
columnSpecifications: ColumnDefinition[]
}> = ({ columnSpecifications, row, rowIndex }) => {
return (
<tr>
<RowSelector index={rowIndex} />
{row.cells.map((cell, cellIndex) => (
<Cell
key={cellIndex}
cellData={cell}
rowIndex={rowIndex}
row={row}
columnIndex={normalizedCellIndex(row, cellIndex)}
columnSpecification={
columnSpecifications[normalizedCellIndex(row, cellIndex)]
}
/>
))}
</tr>
)
}

View File

@@ -0,0 +1,69 @@
import { MouseEventHandler, useCallback } from 'react'
import {
TableSelection,
useSelectionContext,
} from './contexts/selection-context'
import classNames from 'classnames'
import { useTableContext } from './contexts/table-context'
export const ColumnSelector = ({ index }: { index: number }) => {
const { selection, setSelection } = useSelectionContext()
const { table } = useTableContext()
const onColumnSelect: MouseEventHandler = useCallback(
event => {
event.preventDefault()
if (!selection) {
setSelection(
new TableSelection(
{ row: 0, cell: index },
{ row: table.rows.length - 1, cell: index }
)
)
return
}
setSelection(selection.selectColumn(index, event.shiftKey, table))
},
[index, setSelection, selection, table]
)
const fullySelected = selection?.isColumnSelected(index, table)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
onMouseDown={onColumnSelect}
className={classNames('table-generator-selector-cell column-selector', {
'fully-selected': fullySelected,
})}
/>
)
}
export const RowSelector = ({ index }: { index: number }) => {
const { table } = useTableContext()
const { selection, setSelection } = useSelectionContext()
const onSelect: MouseEventHandler = useCallback(
event => {
event.preventDefault()
if (!selection) {
setSelection(
new TableSelection(
{ row: index, cell: 0 },
{ row: index, cell: table.columns.length - 1 }
)
)
return
}
setSelection(selection.selectRow(index, event.shiftKey, table))
},
[index, setSelection, table, selection]
)
const fullySelected = selection?.isRowSelected(index, table)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
onMouseDown={onSelect}
className={classNames('table-generator-selector-cell row-selector', {
'fully-selected': fullySelected,
})}
/>
)
}

View File

@@ -0,0 +1,409 @@
import {
FC,
KeyboardEvent,
KeyboardEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
import { Row } from './row'
import { ColumnSelector } from './selectors'
import {
TableSelection,
useSelectionContext,
} from './contexts/selection-context'
import { useEditingContext } from './contexts/editing-context'
import { useTableContext } from './contexts/table-context'
import { useCodeMirrorViewContext } from '../codemirror-context'
import { undo, redo } from '@codemirror/commands'
import { ChangeSpec } from '@codemirror/state'
import { startCompileKeypress } from '@/features/pdf-preview/hooks/use-compile-triggers'
import { useTabularContext } from './contexts/tabular-context'
import { ColumnSizeIndicator } from './column-size-indicator'
import { isMac } from '@/shared/utils/os'
type NavigationKey =
| 'ArrowRight'
| 'ArrowLeft'
| 'ArrowUp'
| 'ArrowDown'
| 'Tab'
type NavigationMap = {
// eslint-disable-next-line no-unused-vars
[key in NavigationKey]: {
run: () => TableSelection
shift: () => TableSelection
canExitEditing?: boolean
}
}
const MINIMUM_CELL_WIDTH_CHARACTERS = 15
const MINIMUM_EDITING_CELL_WIDTH_CHARACTERS = 20
const CELL_WIDTH_BUFFER = 3 // characters
export const Table: FC = () => {
const { selection, setSelection } = useSelectionContext()
const {
cellData,
cancelEditing,
startEditing,
commitCellData,
clearCells,
updateCellData,
} = useEditingContext()
const { table: tableData } = useTableContext()
const { openColumnWidthModal, columnWidthModalShown } = useTabularContext()
const tableRef = useRef<HTMLTableElement>(null)
const view = useCodeMirrorViewContext()
const { columns: cellWidths, tableWidth } = useMemo(() => {
const columns = Array.from(
{ length: tableData.columns.length },
() => MINIMUM_CELL_WIDTH_CHARACTERS
)
// First pass, calculate the optimal width of each column. For the cell
// we're editing, make sure there's space to write into as well
// (MINIMUM_EDITING_CELL_WIDTH_CHARACTERS)
for (let row = 0; row < tableData.rows.length; ++row) {
for (
let i = 0;
i < tableData.columns.length;
i += tableData.getCell(row, i).multiColumn?.columnSpan ?? 1
) {
const columnSpan =
tableData.getCell(row, i).multiColumn?.columnSpan ?? 1
let contentLength =
tableData.getCell(row, i).content.length + CELL_WIDTH_BUFFER
if (cellData?.rowIndex === row && cellData?.cellIndex === i) {
contentLength = Math.max(
contentLength,
Math.min(
cellData.content.length + CELL_WIDTH_BUFFER,
MINIMUM_EDITING_CELL_WIDTH_CHARACTERS
)
)
}
for (let j = 0; j < columnSpan; ++j) {
columns[i + j] = Math.max(columns[i + j], contentLength / columnSpan)
}
}
}
// Second pass, use a logarithmic scale to not drown out narrow columns
// completely
const total = columns.reduce((a, b) => a + b, 0)
for (let i = 0; i < columns.length; ++i) {
columns[i] = Math.log2(columns[i])
}
// Third pass, normalize the columns to the total width of the table
const totalLog = columns.reduce((a, b) => a + b, 0)
for (let i = 0; i < columns.length; ++i) {
columns[i] = Math.round((columns[i] / totalLog) * 100)
}
return { columns, tableWidth: total }
}, [
tableData,
cellData?.cellIndex,
cellData?.rowIndex,
cellData?.content.length,
])
const navigation: NavigationMap = useMemo(
() => ({
ArrowRight: {
run: () => selection!.moveRight(tableData),
shift: () => selection!.extendRight(tableData),
},
ArrowLeft: {
run: () => selection!.moveLeft(tableData),
shift: () => selection!.extendLeft(tableData),
},
ArrowUp: {
run: () => selection!.moveUp(tableData),
shift: () => selection!.extendUp(tableData),
},
ArrowDown: {
run: () => selection!.moveDown(tableData),
shift: () => selection!.extendDown(tableData),
},
Tab: {
run: () => selection!.moveNext(tableData),
shift: () => selection!.movePrevious(tableData),
canExitEditing: true,
},
}),
[selection, tableData]
)
const isCharacterInput = useCallback((event: KeyboardEvent) => {
return (
Boolean(event.code) && // is a keyboard key
event.key?.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
)
}, [])
const onKeyDown: KeyboardEventHandler = useCallback(
event => {
if (startCompileKeypress(event)) {
return
}
if (view.state.readOnly) {
return
}
const commandKey = isMac ? event.metaKey : event.ctrlKey
if (event.code === 'Enter' && !event.shiftKey) {
event.preventDefault()
event.stopPropagation()
if (!selection) {
return
}
if (cellData) {
commitCellData()
} else {
const initialContent = tableData.getCell(
selection.to.row,
selection.to.cell
).content
startEditing(selection.to.row, selection.to.cell, initialContent)
}
setSelection(
new TableSelection(selection.to, selection.to).explode(tableData)
)
} else if (event.code === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (!cellData) {
setSelection(null)
view.focus()
} else {
cancelEditing()
}
} else if (event.code === 'Delete' || event.code === 'Backspace') {
if (cellData) {
return
}
if (!selection) {
return
}
event.preventDefault()
event.stopPropagation()
clearCells(selection)
view.requestMeasure()
setTimeout(() => {
if (tableRef.current) {
const { minY } = selection.normalized()
const row = tableRef.current.querySelectorAll('tbody tr')[minY]
if (row) {
if (row.getBoundingClientRect().top < 0) {
row.scrollIntoView({ block: 'center' })
}
}
}
}, 0)
} else if (Object.prototype.hasOwnProperty.call(navigation, event.code)) {
const {
run: defaultNavigation,
shift: shiftNavigation,
canExitEditing,
} = navigation[event.code as NavigationKey]
if (cellData) {
if (!canExitEditing) {
return
}
}
event.preventDefault()
if (!selection) {
setSelection(
new TableSelection(
{ row: 0, cell: 0 },
{ row: 0, cell: 0 }
).explode(tableData)
)
return
}
const newSelection = event.shiftKey
? shiftNavigation()
: defaultNavigation()
if (cellData && canExitEditing) {
commitCellData()
}
setSelection(newSelection)
} else if (isCharacterInput(event) && !cellData) {
event.preventDefault()
event.stopPropagation()
if (!selection) {
return
}
startEditing(selection.to.row, selection.to.cell)
updateCellData(event.key)
setSelection(new TableSelection(selection.to, selection.to))
} else if (
!cellData &&
event.key === 'z' &&
!event.shiftKey &&
commandKey
) {
event.preventDefault()
undo(view)
} else if (
!cellData &&
(event.key === 'y' ||
(event.key === 'Z' && event.shiftKey && commandKey))
) {
event.preventDefault()
redo(view)
}
},
[
selection,
cellData,
setSelection,
cancelEditing,
startEditing,
commitCellData,
navigation,
view,
clearCells,
updateCellData,
isCharacterInput,
tableData,
]
)
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
if (view.state.readOnly) {
return false
}
if (cellData || !selection || columnWidthModalShown) {
// We're editing a cell, or modifying column widths,
// so allow browser to insert there
return false
}
event.preventDefault()
const changes: ChangeSpec[] = []
const data = event.clipboardData?.getData('text/plain')
if (data) {
const cells = data.split('\n').map(row => row.split('\t'))
const { minY, minX } = selection.normalized()
for (let row = 0; row < cells.length; ++row) {
const rowIndex = minY + row
if (rowIndex >= tableData.rows.length) {
// TODO: add more rows
break
}
const cellStart = tableData.getCellIndex(rowIndex, minX)
for (let column = 0; column < cells[row].length; ++column) {
const cellIndex = cellStart + column
if (cellIndex >= tableData.rows[rowIndex].cells.length) {
// TODO: add more columns
break
}
const cell = tableData.rows[rowIndex].cells[cellIndex]
changes.push({
from: cell.from,
to: cell.to,
insert: cells[row][column],
})
}
}
}
view.dispatch({ changes })
}
const onCopy = (event: ClipboardEvent) => {
if (cellData || !selection || columnWidthModalShown) {
// We're editing a cell, or modifying column widths,
// so allow browser to copy from there
return false
}
event.preventDefault()
const { minY, maxY } = selection.normalized()
const cells: string[][] = Array.from(
{ length: maxY - minY + 1 },
() => []
)
tableData.iterateSelection(selection, (cell, row) => {
cells[row - minY].push(cell.content)
})
const content = cells.map(row => row.join('\t')).join('\n')
navigator.clipboard.writeText(content)
}
window.addEventListener('paste', onPaste)
window.addEventListener('copy', onCopy)
return () => {
window.removeEventListener('paste', onPaste)
window.removeEventListener('copy', onCopy)
}
}, [cellData, selection, tableData, view, columnWidthModalShown])
const hasCustomSizes = useMemo(
() => tableData.columns.some(x => x.size),
[tableData.columns]
)
const onSizeClick = (index: number) => {
setSelection(
new TableSelection(
{ row: 0, cell: index },
{ row: tableData.rows.length - 1, cell: index }
)
)
openColumnWidthModal()
}
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<table
className="table-generator-table"
onKeyDown={onKeyDown}
tabIndex={-1}
ref={tableRef}
style={{ width: `min(${tableWidth}ch, 95%)` }}
>
<colgroup>
<col width="20" />
{tableData.columns.map((_, index) => (
<col key={index} width={`${cellWidths[index]}%`} />
))}
</colgroup>
<thead>
{hasCustomSizes && (
<tr className="table-generator-column-widths-row">
<td />
{tableData.columns.map((column, columnIndex) => (
<td align="center" key={columnIndex}>
{column.size && (
<ColumnSizeIndicator
size={column.size}
onClick={() => onSizeClick(columnIndex)}
/>
)}
</td>
))}
</tr>
)}
<tr>
<td />
{tableData.columns.map((_, columnIndex) => (
<ColumnSelector index={columnIndex} key={columnIndex} />
))}
</tr>
</thead>
<tbody>
{tableData.rows.map((row, rowIndex) => (
<Row
row={row}
rowIndex={rowIndex}
key={rowIndex}
columnSpecifications={tableData.columns}
/>
))}
</tbody>
</table>
)
}

View File

@@ -0,0 +1,310 @@
import { SyntaxNode } from '@lezer/common'
import { FC, useEffect } from 'react'
import { CellPosition, ParsedTableData, RowPosition } from './utils'
import { Toolbar } from './toolbar/toolbar'
import { Table } from './table'
import {
SelectionContextProvider,
TableSelection,
useSelectionContext,
} from './contexts/selection-context'
import {
EditingContextProvider,
useEditingContext,
} from './contexts/editing-context'
import { EditorView } from '@codemirror/view'
import { ErrorBoundary } from 'react-error-boundary'
import { EditorSelection } from '@codemirror/state'
import {
CodeMirrorViewContext,
useCodeMirrorViewContext,
} from '../codemirror-context'
import { TableProvider } from './contexts/table-context'
import { TabularProvider, useTabularContext } from './contexts/tabular-context'
import { BorderTheme } from './toolbar/commands'
import { TableGeneratorHelpModal } from './help-modal'
import { SplitTestProvider } from '../../../../shared/context/split-test-context'
import { useTranslation } from 'react-i18next'
import { ColumnWidthModal } from './toolbar/column-width-modal/modal'
import { WidthSelection } from './toolbar/column-width-modal/column-width'
import Notification from '@/shared/components/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
export type ColumnDefinition = {
alignment: 'left' | 'center' | 'right' | 'paragraph'
borderLeft: number
borderRight: number
content: string
cellSpacingLeft: string
cellSpacingRight: string
customCellDefinition: string
isParagraphColumn: boolean
size?: WidthSelection
}
export type CellData = {
content: string
from: number
to: number
multiColumn?: {
columnSpan: number
columns: {
specification: ColumnDefinition[]
from: number
to: number
}
from: number
to: number
preamble: {
from: number
to: number
}
postamble: {
from: number
to: number
}
}
}
export type RowData = {
cells: CellData[]
borderTop: number
borderBottom: number
}
export class TableData {
// eslint-disable-next-line no-useless-constructor
constructor(
public readonly rows: RowData[],
public readonly columns: ColumnDefinition[]
) {}
getCellIndex(row: number, column: number): number {
let cellIndex = 0
for (let i = 0; i < this.rows[row].cells.length; i++) {
cellIndex += this.rows[row].cells[i].multiColumn?.columnSpan ?? 1
if (column < cellIndex) {
return i
}
}
return this.rows[row].cells.length - 1
}
iterateCells(
minRow: number,
maxRow: number,
minColumn: number,
maxColumn: number,
callback: (cell: CellData, row: number, column: number) => void
) {
for (let row = minRow; row <= maxRow; ++row) {
let currentCellOffset = this.getCellBoundaries(row, minColumn).from
const minX = this.getCellIndex(row, minColumn)
const maxX = this.getCellIndex(row, maxColumn)
for (let column = minX; column <= maxX; ++column) {
const currentCell = this.rows[row].cells[column]
const skip = currentCell.multiColumn?.columnSpan ?? 1
callback(currentCell, row, currentCellOffset)
currentCellOffset += skip
}
}
}
iterateSelection(
selection: TableSelection,
callback: (cell: CellData, row: number, column: number) => void
) {
const { minX, maxX, minY, maxY } = selection.normalized()
this.iterateCells(minY, maxY, minX, maxX, callback)
}
getCell(row: number, column: number): CellData {
return this.rows[row].cells[this.getCellIndex(row, column)]
}
getCellBoundaries(row: number, cell: number) {
let currentCellOffset = 0
for (let index = 0; index < this.rows[row].cells.length; ++index) {
const currentCell = this.rows[row].cells[index]
const skip = currentCell.multiColumn?.columnSpan ?? 1
if (currentCellOffset + skip > cell) {
return { from: currentCellOffset, to: currentCellOffset + skip - 1 }
}
currentCellOffset += skip
}
throw new Error("Couldn't find cell boundaries")
}
getBorderTheme(): BorderTheme | null {
if (this.rows.length === 0 || this.columns.length === 0) {
return null
}
const lastRow = this.rows[this.rows.length - 1]
const hasBottomBorder = lastRow.borderBottom > 0
const firstColumn = this.columns[0]
const hasLeftBorder = firstColumn.borderLeft > 0
for (const row of this.rows) {
if (hasBottomBorder === (row.borderTop === 0)) {
return null
}
}
// If we had the first, we have verified that we have the rest
const hasAllRowBorders = hasBottomBorder
for (const column of this.columns) {
if (hasLeftBorder === (column.borderRight === 0)) {
return null
}
}
for (const row of this.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
if (cell.multiColumn.columns.specification.length === 0) {
return null
}
const firstCell = cell.multiColumn.columns.specification[0]
if (hasLeftBorder === (firstCell.borderLeft === 0)) {
return null
}
for (const column of cell.multiColumn.columns.specification) {
if (hasLeftBorder === (column.borderRight === 0)) {
return null
}
}
}
}
}
// If we had the first, we have verified that we have the rest
const hasAllColumnBorders = hasLeftBorder
if (hasAllRowBorders && hasAllColumnBorders) {
return BorderTheme.FULLY_BORDERED
} else {
return BorderTheme.NO_BORDERS
}
}
}
export type Positions = {
cells: CellPosition[][]
columnDeclarations: { from: number; to: number }
rowPositions: RowPosition[]
tabular: { from: number; to: number }
}
export const TableRenderingError: FC<{
view: EditorView
codePosition?: number
}> = ({ view, codePosition }) => {
const { t } = useTranslation()
return (
<Notification
type="info"
content={
<>
<p>
<strong>
{t('sorry_your_table_cant_be_displayed_at_the_moment')}
</strong>
</p>
<p>
{t(
'this_could_be_because_we_cant_support_some_elements_of_the_table'
)}
</p>
</>
}
action={
codePosition !== undefined ? (
<OLButton
variant="secondary"
onClick={() =>
view.dispatch({
selection: EditorSelection.cursor(codePosition),
})
}
size="sm"
>
{t('view_code')}
</OLButton>
) : undefined
}
/>
)
}
export const Tabular: FC<{
tabularNode: SyntaxNode
view: EditorView
tableNode: SyntaxNode | null
parsedTableData: ParsedTableData
directTableChild?: boolean
}> = ({ tabularNode, view, tableNode, parsedTableData, directTableChild }) => {
return (
<ErrorBoundary
fallbackRender={() => (
<TableRenderingError view={view} codePosition={tabularNode.from} />
)}
>
<SplitTestProvider>
<CodeMirrorViewContext.Provider value={view}>
<TabularProvider>
<TableProvider
tabularNode={tabularNode}
tableData={parsedTableData}
tableNode={tableNode}
directTableChild={directTableChild}
view={view}
>
<SelectionContextProvider>
<EditingContextProvider>
<TabularWrapper />
</EditingContextProvider>
<ColumnWidthModal />
</SelectionContextProvider>
</TableProvider>
<TableGeneratorHelpModal />
</TabularProvider>
</CodeMirrorViewContext.Provider>
</SplitTestProvider>
</ErrorBoundary>
)
}
const TabularWrapper: FC = () => {
const { setSelection, selection } = useSelectionContext()
const { commitCellData, cellData } = useEditingContext()
const { ref } = useTabularContext()
const view = useCodeMirrorViewContext()
useEffect(() => {
const listener: (event: MouseEvent) => void = event => {
if (
!ref.current?.contains(event.target as Node) &&
!(event.target as HTMLElement).closest('.table-generator-help-modal') &&
!(event.target as HTMLElement).closest('.table-generator-width-modal')
) {
if (selection) {
setSelection(null)
}
if (cellData) {
commitCellData()
}
} else {
view.dispatch() // trigger a state update when clicking inside the table
}
}
window.addEventListener('mousedown', listener)
return () => {
window.removeEventListener('mousedown', listener)
}
}, [cellData, commitCellData, selection, setSelection, ref, view])
return (
<div className="table-generator" ref={ref}>
{!view.state.readOnly && <Toolbar />}
<Table />
</div>
)
}

View File

@@ -0,0 +1,45 @@
export const ABSOLUTE_SIZE_REGEX = /[pbm]\{\s*(\d*[.]?\d+)\s*(mm|cm|pt|in)\s*\}/
export const RELATIVE_SIZE_REGEX =
/[pbm]\{\s*(\d*[.]?\d+)\s*\\(linewidth|textwidth|columnwidth)\s*\}/
export type AbsoluteWidthUnits = 'mm' | 'cm' | 'in' | 'pt'
export type RelativeWidthCommand = 'linewidth' | 'textwidth' | 'columnwidth'
export type WidthUnit = AbsoluteWidthUnits | '%' | 'custom'
const ABSOLUTE_UNITS = ['mm', 'cm', 'in', 'pt'] as const
export const UNITS: WidthUnit[] = ['%', ...ABSOLUTE_UNITS, 'custom']
type PercentageWidth = {
unit: '%'
width: number
command?: RelativeWidthCommand
}
type CustomWidth = {
unit: 'custom'
width: string
}
type AbsoluteWidth = {
unit: Exclude<WidthUnit, '%' | 'custom'>
width: number
}
export type WidthSelection = PercentageWidth | CustomWidth | AbsoluteWidth
export const isPercentageWidth = (
width: WidthSelection
): width is PercentageWidth => {
return width.unit === '%'
}
export const isAbsoluteWidth = (
width: WidthSelection
): width is AbsoluteWidth => {
return (ABSOLUTE_UNITS as readonly string[]).includes(width.unit)
}
export const isCustomWidth = (width: WidthSelection): width is CustomWidth => {
return width.unit === 'custom'
}

View File

@@ -0,0 +1,233 @@
import { useTabularContext } from '../../contexts/tabular-context'
import { Select } from '@/shared/components/select'
import { Trans, useTranslation } from 'react-i18next'
import {
FormEventHandler,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useSelectionContext } from '../../contexts/selection-context'
import { useTableContext } from '../../contexts/table-context'
import { setColumnWidth } from '../commands'
import { UNITS, WidthSelection, WidthUnit } from './column-width'
import { useCodeMirrorViewContext } from '../../../codemirror-context'
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLForm from '@/features/ui/components/ol/ol-form'
import MaterialIcon from '@/shared/components/material-icon'
type UnitDescription = { label: string; tooltip?: string } | undefined
export const ColumnWidthModal = memo(function ColumnWidthModal() {
const { columnWidthModalShown } = useTabularContext()
if (!columnWidthModalShown) {
return null
}
return <ColumnWidthModalBody />
})
const ColumnWidthModalBody = () => {
const { columnWidthModalShown, closeColumnWidthModal } = useTabularContext()
const view = useCodeMirrorViewContext()
const { selection } = useSelectionContext()
const { positions, table } = useTableContext()
const { t } = useTranslation()
const [currentUnit, setCurrentUnit] = useState<WidthUnit | undefined | null>(
'%'
)
const [currentWidth, setCurrentWidth] = useState<string>('')
const inputRef = useRef<HTMLInputElement>(null)
const unitHelp: UnitDescription = useMemo(() => {
switch (currentUnit) {
case '%':
return {
label: t('percent_is_the_percentage_of_the_line_width'),
tooltip: t(
'line_width_is_the_width_of_the_line_in_the_current_environment'
),
}
case 'custom':
return {
label: t('enter_any_size_including_units_or_valid_latex_command'),
}
default:
return undefined
}
}, [currentUnit, t])
useEffect(() => {
if (columnWidthModalShown) {
inputRef.current?.focus()
if (
!selection ||
selection.width() !== 1 ||
!table.columns[selection.to.cell].isParagraphColumn ||
!table.columns[selection.to.cell].size
) {
setCurrentUnit('%')
setCurrentWidth('')
return
}
const { to } = selection
const columnIndexToReadWidthAndUnit = to.cell
const column = table.columns[columnIndexToReadWidthAndUnit]
const size = column.size!
if (size.unit === '%') {
setCurrentUnit('%')
const widthWithUpToTwoDecimalPlaces = Math.round(size.width * 100) / 100
setCurrentWidth(widthWithUpToTwoDecimalPlaces.toString())
} else if (size.unit === 'custom') {
setCurrentUnit('custom')
// Slice off p{ and }
setCurrentWidth(column.content.slice(2, -1))
} else {
setCurrentUnit(size.unit)
setCurrentWidth(size.width.toString())
}
}
}, [columnWidthModalShown, selection, table])
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
e => {
e.preventDefault()
if (selection && currentUnit) {
const currentWidthNumber = parseFloat(currentWidth)
let newWidth: WidthSelection
if (currentUnit === 'custom') {
newWidth = { unit: 'custom', width: currentWidth }
} else {
newWidth = { unit: currentUnit, width: currentWidthNumber }
}
setColumnWidth(view, selection, newWidth, positions, table)
}
closeColumnWidthModal()
return false
},
[
closeColumnWidthModal,
currentUnit,
currentWidth,
positions,
selection,
table,
view,
]
)
return (
<OLModal
show={columnWidthModalShown}
onHide={closeColumnWidthModal}
className="table-generator-width-modal"
>
<OLModalHeader closeButton>
<OLModalTitle>{t('set_column_width')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<OLForm id="table-generator-width-form" onSubmit={onSubmit}>
<OLRow className="g-3">
<OLCol lg={8}>
<OLFormGroup
controlId="column-width-modal-width"
className="mb-0"
>
<OLFormLabel>{t('column_width')}</OLFormLabel>
<OLFormControl
value={currentWidth}
required
onChange={e => setCurrentWidth(e.target.value)}
type={currentUnit === 'custom' ? 'text' : 'number'}
ref={inputRef}
/>
</OLFormGroup>
</OLCol>
<OLCol lg={4}>
<OLFormGroup className="mb-0">
<Select
label={
<>
&nbsp;<span className="sr-only">{t('length_unit')}</span>
</>
}
items={UNITS}
itemToKey={x => x ?? ''}
itemToString={x => (x === 'custom' ? t('custom') : (x ?? ''))}
onSelectedItemChanged={item => setCurrentUnit(item)}
defaultItem={currentUnit}
/>
</OLFormGroup>
</OLCol>
</OLRow>
{unitHelp && (
<p className="my-1">
{unitHelp.label}{' '}
{unitHelp.tooltip && (
<OLTooltip
id="table-generator-unit-tooltip"
description={unitHelp.tooltip}
overlayProps={{ delay: 0, placement: 'top' }}
>
<span>
<MaterialIcon type="help" className="align-middle" />
</span>
</OLTooltip>
)}
</p>
)}
<div className="mt-2">
<Trans
i18nKey="to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package"
// eslint-disable-next-line react/jsx-key
components={[<b />, <code />]}
/>
</div>
<div className="mt-1 table-generator-usepackage-copy">
<code>
\usepackage{'{'}array{'}'}
</code>
<CopyToClipboard
content={
'\\usepackage{array} % required for text wrapping in tables'
}
tooltipId="table-generator-array-copy"
/>
</div>
</OLForm>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={() => {
closeColumnWidthModal()
}}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
form="table-generator-width-form"
type="submit"
>
{t('ok')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,799 @@
import { EditorView } from '@codemirror/view'
import { ColumnDefinition, Positions, TableData } from '../tabular'
import { ChangeSpec, EditorSelection } from '@codemirror/state'
import {
CellSeparator,
RowSeparator,
parseColumnSpecifications,
} from '../utils'
import { TableSelection } from '../contexts/selection-context'
import { ensureEmptyLine } from '../../../extensions/toolbar/commands'
import { TableEnvironmentData } from '../contexts/table-context'
import {
extendBackwardsOverEmptyLines,
extendForwardsOverEmptyLines,
} from '../../../extensions/visual/selection'
import { debugConsole } from '@/utils/debugging'
import { WidthSelection } from './column-width-modal/column-width'
/* eslint-disable no-unused-vars */
export enum BorderTheme {
NO_BORDERS = 0,
FULLY_BORDERED = 1,
}
/* eslint-enable no-unused-vars */
export const setBorders = (
view: EditorView,
theme: BorderTheme,
positions: Positions,
rowSeparators: RowSeparator[],
table: TableData
) => {
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
if (theme === BorderTheme.NO_BORDERS) {
const removeColumnBorders = view.state.changes({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: specification.replace(/\|/g, ''),
})
const removeHlines: ChangeSpec[] = []
for (const row of positions.rowPositions) {
for (const hline of row.hlines) {
removeHlines.push({
from: hline.from,
to: hline.to,
insert: '',
})
}
}
const removeMulticolumnBorders: ChangeSpec[] = []
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
const specification = view.state.sliceDoc(
cell.multiColumn.columns.from,
cell.multiColumn.columns.to
)
removeMulticolumnBorders.push({
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: specification.replace(/\|/g, ''),
})
}
}
}
view.dispatch({
changes: [
removeColumnBorders,
...removeHlines,
...removeMulticolumnBorders,
],
})
} else if (theme === BorderTheme.FULLY_BORDERED) {
const newSpec = generateColumnSpecification(
addColumnBordersToSpecification(table.columns)
)
const insertColumns = view.state.changes({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpec,
})
const insertHlines: ChangeSpec[] = []
for (const row of positions.rowPositions) {
if (row.hlines.length === 0) {
insertHlines.push(
view.state.changes({
from: row.from,
to: row.from,
insert: ' \\hline ',
})
)
}
}
const lastRow = positions.rowPositions[positions.rowPositions.length - 1]
if (lastRow.hlines.length < 2) {
let toInsert = ' \\hline'
if (rowSeparators.length < positions.rowPositions.length) {
// We need a trailing \\
toInsert = ` \\\\${toInsert}`
}
insertHlines.push(
view.state.changes({
from: lastRow.to,
to: lastRow.to,
insert: toInsert,
})
)
}
const addMulticolumnBorders: ChangeSpec[] = []
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
addMulticolumnBorders.push({
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: generateColumnSpecification(
addColumnBordersToSpecification(
cell.multiColumn.columns.specification
)
),
})
}
}
}
view.dispatch({
changes: [insertColumns, ...insertHlines, ...addMulticolumnBorders],
})
}
}
const addColumnBordersToSpecification = (specification: ColumnDefinition[]) => {
const newSpec = specification.map(column => ({
...column,
borderLeft: 1,
borderRight: 0,
}))
newSpec[newSpec.length - 1].borderRight = 1
return newSpec
}
export const setAlignment = (
view: EditorView,
selection: TableSelection,
alignment: ColumnDefinition['alignment'],
positions: Positions,
table: TableData
) => {
if (selection.isMergedCellSelected(table)) {
if (alignment === 'paragraph') {
// shouldn't happen
return
}
// change for mergedColumn
const { minX, minY } = selection.normalized()
const cell = table.getCell(minY, minX)
if (!cell.multiColumn) {
return
}
const specification = view.state.sliceDoc(
cell.multiColumn.columns.from,
cell.multiColumn.columns.to
)
const columnSpecification = parseColumnSpecifications(specification)
for (const column of columnSpecification) {
if (column.alignment !== alignment) {
column.alignment = alignment
column.content = alignment[0]
}
}
const newSpec = generateColumnSpecification(columnSpecification)
view.dispatch({
changes: {
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: newSpec,
},
})
return
}
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(specification)
const { minX, maxX } = selection.normalized()
for (let i = minX; i <= maxX; i++) {
if (selection.isColumnSelected(i, table)) {
if (columnSpecification[i].alignment === alignment) {
continue
}
columnSpecification[i].alignment = alignment
if (columnSpecification[i].isParagraphColumn) {
columnSpecification[i].customCellDefinition =
generateParagraphColumnSpecification(alignment)
} else {
if (alignment === 'paragraph') {
// shouldn't happen
continue
}
columnSpecification[i].content = alignment[0]
}
}
}
const newSpecification = generateColumnSpecification(columnSpecification)
view.dispatch({
changes: [
{
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpecification,
},
],
})
}
const generateColumnSpecification = (columns: ColumnDefinition[]) => {
return columns
.map(
({
borderLeft,
borderRight,
content,
cellSpacingLeft,
cellSpacingRight,
customCellDefinition,
}) =>
`${'|'.repeat(
borderLeft
)}${cellSpacingLeft}${customCellDefinition}${content}${cellSpacingRight}${'|'.repeat(
borderRight
)}`
)
.join('')
}
export const removeRowOrColumns = (
view: EditorView,
selection: TableSelection,
positions: Positions,
cellSeparators: CellSeparator[][],
table: TableData
) => {
const {
minX: startCell,
maxX: endCell,
minY: startRow,
maxY: endRow,
} = selection.normalized()
const borderTheme = table.getBorderTheme()
const changes: { from: number; to: number; insert: string }[] = []
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(specification)
const numberOfColumns = columnSpecification.length
const numberOfRows = positions.rowPositions.length
if (selection.spansEntireTable(table)) {
emptyTable(view, columnSpecification, positions)
return new TableSelection({ cell: 0, row: 0 })
}
const removedRows =
Number(selection.isRowSelected(startRow, table)) * selection.height()
const removedColumns =
Number(selection.isColumnSelected(startCell, table)) * selection.width()
const removingFromBeginning = selection.isColumnSelected(0, table)
for (let row = startRow; row <= endRow; row++) {
if (selection.isRowSelected(row, table)) {
const rowPosition = positions.rowPositions[row]
let insert = ''
if (
row === numberOfRows - 1 &&
borderTheme === BorderTheme.FULLY_BORDERED
) {
insert = '\\hline'
}
changes.push({
from: rowPosition.from,
to: rowPosition.to,
insert,
})
} else {
for (let cell = startCell; cell <= endCell; ) {
const cellIndex = table.getCellIndex(row, cell)
const cellPosition = positions.cells[row][cellIndex]
if (selection.isColumnSelected(cell, table)) {
// We should remove this column.
if (removingFromBeginning) {
// Deletes content in { }
// [ cell x - 1 ] & { [ cell x ] & } [ cell x + 1 ]
const from = cellPosition.from
const to = cellSeparators[row][cellIndex].to
if (from === undefined || to === undefined) {
debugConsole.error('Failed to remove column')
return selection
}
changes.push({
from,
to,
insert: '',
})
} else {
// Deletes content in { }
// [ cell x - 1 ] { & [ cell x ] } & [ cell x + 1 ]
const from = cellSeparators[row][cellIndex - 1].from
const to = cellPosition.to
if (from === undefined || to === undefined) {
debugConsole.error('Failed to remove column')
return selection
}
changes.push({
from,
to,
insert: '',
})
}
}
cell += table.rows[row].cells[cellIndex].multiColumn?.columnSpan ?? 1
}
}
}
const filteredColumns = columnSpecification.filter(
(_, i) => !selection.isColumnSelected(i, table)
)
if (
table.getBorderTheme() === BorderTheme.FULLY_BORDERED &&
columnSpecification[0]?.borderLeft > 0 &&
filteredColumns.length
) {
filteredColumns[0].borderLeft = Math.max(1, filteredColumns[0].borderLeft)
}
const newSpecification = generateColumnSpecification(filteredColumns)
changes.push({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpecification,
})
view.dispatch({ changes })
const updatedNumberOfRows = numberOfRows - removedRows
const updatedNumberOfColumns = numberOfColumns - removedColumns
// Clamp selection to new table size
return new TableSelection({
cell: Math.max(0, Math.min(updatedNumberOfColumns - 1, startCell)),
row: Math.max(0, Math.min(updatedNumberOfRows - 1, startRow)),
})
}
const emptyTable = (
view: EditorView,
columnSpecification: ColumnDefinition[],
positions: Positions
) => {
const newColumns = columnSpecification.slice(0, 1)
newColumns[0].borderLeft = 0
newColumns[0].borderRight = 0
const newSpecification = generateColumnSpecification(newColumns)
const changes: ChangeSpec[] = []
changes.push({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpecification,
})
const from = positions.rowPositions[0].from
const to = positions.rowPositions[positions.rowPositions.length - 1].to
changes.push({
from,
to,
insert: '\\\\',
})
view.dispatch({ changes })
}
export const insertRow = (
view: EditorView,
selection: TableSelection,
positions: Positions,
below: boolean,
rowSeparators: RowSeparator[],
table: TableData
) => {
const { maxY, minY } = selection.normalized()
const rowsToInsert = selection.height()
const from = below
? positions.rowPositions[maxY].to
: positions.rowPositions[minY].from
const numberOfColumns = table.columns.length
const borderTheme = table.getBorderTheme()
const border = borderTheme === BorderTheme.FULLY_BORDERED ? '\\hline' : ''
const initialRowSeparator =
below && rowSeparators.length === table.rows.length - 1 ? '\\\\' : ''
const initialHline =
borderTheme === BorderTheme.FULLY_BORDERED && !below && minY === 0
? '\\hline'
: ''
const insert = `${initialRowSeparator}${initialHline}\n${' &'.repeat(
numberOfColumns - 1
)}\\\\${border}`.repeat(rowsToInsert)
view.dispatch({ changes: { from, to: from, insert } })
if (!below) {
return selection
}
return new TableSelection(
{ cell: 0, row: maxY + 1 },
{ cell: numberOfColumns - 1, row: maxY + 1 }
)
}
export const insertColumn = (
view: EditorView,
initialSelection: TableSelection,
positions: Positions,
after: boolean,
table: TableData
) => {
const selection = initialSelection.explode(table)
const { maxX, minX } = selection.normalized()
const columnsToInsert = selection.maximumCellWidth(table)
const changes: ChangeSpec[] = []
const targetColumn = after ? maxX : minX
for (let row = 0; row < positions.rowPositions.length; row++) {
const cell = table.getCell(row, targetColumn)
const target = cell.multiColumn ?? cell
const from = after ? target.to : target.from
changes.push({
from,
insert: ' &'.repeat(columnsToInsert),
})
}
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(specification)
const borderTheme = table.getBorderTheme()
const borderRight = borderTheme === BorderTheme.FULLY_BORDERED ? 1 : 0
const targetIndex = after ? maxX + 1 : minX
columnSpecification.splice(
targetIndex,
0,
...Array.from({ length: columnsToInsert }, () => ({
alignment: 'left' as const,
borderLeft: 0,
borderRight,
content: 'l',
cellSpacingLeft: '',
cellSpacingRight: '',
customCellDefinition: '',
isParagraphColumn: false,
}))
)
if (targetIndex === 0 && borderTheme === BorderTheme.FULLY_BORDERED) {
columnSpecification[0].borderLeft = Math.max(
1,
columnSpecification[0].borderLeft
)
}
changes.push({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: generateColumnSpecification(columnSpecification),
})
view.dispatch({ changes })
if (!after) {
return selection
}
return new TableSelection(
{ cell: maxX + 1, row: 0 },
{ cell: maxX + 1, row: positions.rowPositions.length - 1 }
)
}
export const removeNodes = (
view: EditorView,
...nodes: ({ from: number; to: number } | undefined)[]
) => {
const changes: ChangeSpec[] = []
for (const node of nodes) {
if (node !== undefined) {
changes.push({ from: node.from, to: node.to, insert: '' })
}
}
view.dispatch({
changes,
})
}
const contains = (
{ from: outerFrom, to: outerTo }: { from: number; to: number },
{ from: innerFrom, to: innerTo }: { from: number; to: number }
) => {
return outerFrom <= innerFrom && outerTo >= innerTo
}
export const moveCaption = (
view: EditorView,
positions: Positions,
target: 'above' | 'below',
tableEnvironment?: TableEnvironmentData
) => {
const changes: ChangeSpec[] = []
const position =
target === 'above' ? positions.tabular.from : positions.tabular.to
const cursor = EditorSelection.cursor(position)
if (tableEnvironment?.caption) {
const { caption: existingCaption } = tableEnvironment
if (
(existingCaption.from < positions.tabular.from && target === 'above') ||
(existingCaption.from > positions.tabular.to && target === 'below')
) {
// It's already in the right place
return
}
}
const { pos, prefix, suffix } = ensureEmptyLine(view.state, cursor, target)
if (!tableEnvironment?.caption) {
let labelText = '\\label{tab:my_table}'
if (tableEnvironment?.label) {
// We have a label, but no caption. Move the label after our caption
changes.push({
from: tableEnvironment.label.from,
to: tableEnvironment.label.to,
insert: '',
})
labelText = view.state.sliceDoc(
tableEnvironment.label.from,
tableEnvironment.label.to
)
}
changes.push({
...gobbleEmptyLines(view, pos, 2, target),
insert: `${prefix}\\caption{Caption}\n${labelText}${suffix}`,
})
} else {
const { caption: existingCaption, label: existingLabel } = tableEnvironment
// We have a caption, and we need to move it
let currentCaption = view.state.sliceDoc(
existingCaption.from,
existingCaption.to
)
if (existingLabel && !contains(existingCaption, existingLabel)) {
// Move label with it
const labelText = view.state.sliceDoc(
existingLabel.from,
existingLabel.to
)
currentCaption += `\n${labelText}`
changes.push({
from: existingLabel.from,
to: existingLabel.to,
insert: '',
})
}
changes.push({
...gobbleEmptyLines(view, pos, 2, target),
insert: `${prefix}${currentCaption}${suffix}`,
})
// remove exsisting caption
changes.push({
from: existingCaption.from,
to: existingCaption.to,
insert: '',
})
}
view.dispatch({ changes })
}
export const removeCaption = (
view: EditorView,
tableEnvironment?: TableEnvironmentData
) => {
if (tableEnvironment?.caption && tableEnvironment.label) {
if (contains(tableEnvironment.caption, tableEnvironment.label)) {
return removeNodes(view, tableEnvironment.caption)
}
}
return removeNodes(view, tableEnvironment?.caption, tableEnvironment?.label)
}
const gobbleEmptyLines = (
view: EditorView,
pos: number,
lines: number,
target: 'above' | 'below'
) => {
const line = view.state.doc.lineAt(pos)
if (line.length !== 0) {
return { from: pos, to: pos }
}
if (target === 'above') {
return {
from: extendBackwardsOverEmptyLines(view.state.doc, line, lines),
to: pos,
}
}
return {
from: pos,
to: extendForwardsOverEmptyLines(view.state.doc, line, lines),
}
}
export const unmergeCells = (
view: EditorView,
selection: TableSelection,
table: TableData
) => {
const cell = table.getCell(selection.from.row, selection.from.cell)
if (!cell.multiColumn) {
return
}
view.dispatch({
changes: [
{
from: cell.multiColumn.preamble.from,
to: cell.multiColumn.preamble.to,
insert: '',
},
{
from: cell.multiColumn.postamble.from,
to: cell.multiColumn.postamble.to,
insert: '&'.repeat(cell.multiColumn.columnSpan - 1),
},
],
})
}
export const mergeCells = (
view: EditorView,
selection: TableSelection,
table: TableData
) => {
const { minX, maxX, minY, maxY } = selection.normalized()
if (minY !== maxY) {
return
}
if (minX === maxX) {
return
}
const cellContent = []
for (let i = minX; i <= maxX; i++) {
cellContent.push(table.getCell(minY, i).content.trim())
}
const content = cellContent.join(' ').trim()
const border =
table.getBorderTheme() === BorderTheme.FULLY_BORDERED ? '|' : ''
const preamble = `\\multicolumn{${maxX - minX + 1}}{${border}c${border}}{`
const postamble = '}'
const { from } = table.getCell(minY, minX)
const { to } = table.getCell(minY, maxX)
view.dispatch({
changes: {
from,
to,
insert: `${preamble}${content}${postamble}`,
},
})
}
const getSuffixForUnit = (
unit: WidthSelection['unit'],
currentSize?: WidthSelection
) => {
if (unit === 'custom') {
return ''
}
if (unit === '%') {
if (currentSize?.unit === '%' && currentSize.command) {
return `\\${currentSize.command}`
} else {
return '\\linewidth'
}
}
return unit
}
const COMMAND_FOR_PARAGRAPH_ALIGNMENT: Record<
ColumnDefinition['alignment'],
string
> = {
left: '\\raggedright',
right: '\\raggedleft',
center: '\\centering',
paragraph: '',
}
const transformColumnWidth = (width: WidthSelection) => {
if (width.unit === '%') {
return width.width / 100
} else {
return width.width
}
}
const generateParagraphColumnSpecification = (
alignment: ColumnDefinition['alignment']
) => {
if (alignment === 'paragraph') {
return ''
}
return `>{${COMMAND_FOR_PARAGRAPH_ALIGNMENT[alignment]}\\arraybackslash}`
}
function getParagraphAlignmentCharacter(
column: ColumnDefinition
): 'p' | 'm' | 'b' {
if (!column.isParagraphColumn) {
return 'p'
}
const currentAlignmentCharacter = column.content[0]
if (currentAlignmentCharacter === 'm' || currentAlignmentCharacter === 'b') {
return currentAlignmentCharacter
}
return 'p'
}
export const setColumnWidth = (
view: EditorView,
selection: TableSelection,
newWidth: WidthSelection,
positions: Positions,
table: TableData
) => {
const { minX, maxX } = selection.normalized()
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(specification)
for (let i = minX; i <= maxX; i++) {
if (selection.isColumnSelected(i, table)) {
const suffix = getSuffixForUnit(newWidth.unit, table.columns[i].size)
const widthValue = transformColumnWidth(newWidth)
columnSpecification[i].customCellDefinition =
generateParagraphColumnSpecification(columnSpecification[i].alignment)
// Reuse paragraph alignment characters to preserve m and b columns
const alignmentCharacter = getParagraphAlignmentCharacter(
columnSpecification[i]
)
columnSpecification[i].content =
`${alignmentCharacter}{${widthValue}${suffix}}`
}
}
const newSpecification = generateColumnSpecification(columnSpecification)
view.dispatch({
changes: [
{
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpecification,
},
],
})
}
export const removeColumnWidths = (
view: EditorView,
selection: TableSelection,
positions: Positions,
table: TableData
) => {
const { minX, maxX } = selection.normalized()
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(specification)
for (let i = minX; i <= maxX; i++) {
if (selection.isColumnSelected(i, table)) {
columnSpecification[i].customCellDefinition = ''
if (columnSpecification[i].alignment === 'paragraph') {
columnSpecification[i].content = 'l'
columnSpecification[i].alignment = 'left'
} else {
columnSpecification[i].content = columnSpecification[i].alignment[0]
}
}
}
const newSpecification = generateColumnSpecification(columnSpecification)
view.dispatch({
changes: [
{
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpecification,
},
],
})
}

View File

@@ -0,0 +1,93 @@
import { FC, memo, useRef } from 'react'
import useDropdown from '../../../../../shared/hooks/use-dropdown'
import OLListGroup from '@/features/ui/components/ol/ol-list-group'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useTabularContext } from '../contexts/tabular-context'
export const ToolbarButtonMenu: FC<{
id: string
label: string
icon: string
disabled?: boolean
disabledLabel?: string
}> = memo(function ButtonMenu({
icon,
id,
label,
children,
disabled,
disabledLabel,
}) {
const target = useRef<any>(null)
const { open, onToggle, ref } = useDropdown()
const { ref: tableContainerRef } = useTabularContext()
const button = (
<button
type="button"
className="table-generator-toolbar-button table-generator-toolbar-button-menu"
aria-label={label}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={event => {
onToggle(!open)
}}
disabled={disabled}
aria-disabled={disabled}
ref={target}
>
<MaterialIcon type={icon} />
<MaterialIcon type="expand_more" />
</button>
)
const overlay = tableContainerRef.current && (
<OLOverlay
show={open}
target={target.current}
placement="bottom"
container={tableContainerRef.current}
containerPadding={0}
transition
rootClose
onHide={() => onToggle(false)}
>
<OLPopover
id={`${id}-menu`}
ref={ref}
className="table-generator-button-menu-popover"
>
<OLListGroup
role="menu"
onClick={() => {
onToggle(false)
}}
className="d-block"
>
{children}
</OLListGroup>
</OLPopover>
</OLOverlay>
)
return (
<>
<OLTooltip
hidden={open}
id={id}
description={
<div>{disabled && disabledLabel ? disabledLabel : label}</div>
}
overlayProps={{ placement: 'bottom' }}
>
{button}
</OLTooltip>
{overlay}
</>
)
})

View File

@@ -0,0 +1,75 @@
import { EditorView } from '@codemirror/view'
import classNames from 'classnames'
import { memo, useCallback } from 'react'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import { emitTableGeneratorEvent } from '../analytics'
export const ToolbarButton = memo<{
id: string
className?: string
label: string
command?: (view: EditorView) => void
active?: boolean
disabled?: boolean
disabledLabel?: string
icon: string
hidden?: boolean
}>(function ToolbarButton({
id,
className,
label,
command,
active = false,
disabled,
icon,
hidden = false,
disabledLabel,
}) {
const view = useCodeMirrorViewContext()
const handleMouseDown = useCallback(event => {
event.preventDefault()
}, [])
const handleClick = useCallback(
event => {
if (command) {
emitTableGeneratorEvent(view, id)
event.preventDefault()
command(view)
}
},
[command, view, id]
)
const button = (
<button
className={classNames('table-generator-toolbar-button', className, {
hidden,
active,
})}
aria-label={label}
onMouseDown={handleMouseDown}
onClick={!disabled ? handleClick : undefined}
disabled={disabled}
aria-disabled={disabled}
type="button"
>
<MaterialIcon type={icon} />
</button>
)
const description =
disabled && disabledLabel ? <div>{disabledLabel}</div> : <div>{label}</div>
return (
<OLTooltip
id={id}
description={description}
overlayProps={{ placement: 'bottom' }}
>
{button}
</OLTooltip>
)
})

View File

@@ -0,0 +1,142 @@
import { ButtonHTMLAttributes, FC, useCallback, useRef } from 'react'
import useDropdown from '../../../../../shared/hooks/use-dropdown'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useTabularContext } from '../contexts/tabular-context'
import { emitTableGeneratorEvent } from '../analytics'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import classNames from 'classnames'
export const ToolbarDropdown: FC<{
id: string
label?: string
btnClassName?: string
icon?: string
tooltip?: string
disabled?: boolean
disabledTooltip?: string
showCaret?: boolean
}> = ({
id,
label,
children,
btnClassName = 'table-generator-toolbar-dropdown-toggle',
icon = 'expand_more',
tooltip,
disabled,
disabledTooltip,
showCaret,
}) => {
const { open, onToggle, ref } = useDropdown()
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
const { ref: tabularRef } = useTabularContext()
const button = (
<button
ref={toggleButtonRef}
type="button"
id={id}
aria-haspopup="true"
className={btnClassName}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
onToggle(!open)
}}
aria-label={tooltip}
disabled={disabled}
aria-disabled={disabled}
>
{label && <span>{label}</span>}
<MaterialIcon type={icon} />
{showCaret && <MaterialIcon type="expand_more" />}
</button>
)
const overlay = tabularRef.current && (
<OLOverlay
show={open}
target={toggleButtonRef.current}
placement="bottom"
container={tabularRef.current}
transition
rootClose
containerPadding={0}
onHide={() => onToggle(false)}
>
<OLPopover
id={`${id}-popover`}
ref={ref}
className="table-generator-toolbar-dropdown-popover"
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions,
jsx-a11y/click-events-have-key-events */}
<div
className="table-generator-toolbar-dropdown-menu"
id={`${id}-menu`}
aria-labelledby={id}
onClick={() => {
onToggle(false)
}}
>
{children}
</div>
</OLPopover>
</OLOverlay>
)
if (tooltip || (disabled && disabledTooltip)) {
return (
<>
<OLTooltip
hidden={open}
id={id}
description={disabled && disabledTooltip ? disabledTooltip : tooltip}
overlayProps={{ placement: 'bottom' }}
>
{button}
</OLTooltip>
{overlay}
</>
)
}
return (
<>
{button}
{overlay}
</>
)
}
export const ToolbarDropdownItem: FC<
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
command: () => void
id: string
icon?: string
active?: boolean
}
> = ({ children, command, id, icon, active, ...props }) => {
const view = useCodeMirrorViewContext()
const onClick = useCallback(() => {
emitTableGeneratorEvent(view, id)
command()
}, [view, command, id])
return (
<button
className={classNames('ol-cm-toolbar-menu-item', {
'ol-cm-toolbar-dropdown-option-active': active,
})}
role="menuitem"
type="button"
{...props}
onClick={onClick}
>
{icon && <MaterialIcon type={icon} />}
<span className="ol-cm-toolbar-dropdown-option-content">{children}</span>
{active && <MaterialIcon type="check" />}
</button>
)
}

View File

@@ -0,0 +1,468 @@
import { memo, useMemo } from 'react'
import { useSelectionContext } from '../contexts/selection-context'
import { ToolbarButton } from './toolbar-button'
import { ToolbarButtonMenu } from './toolbar-button-menu'
import { ToolbarDropdown, ToolbarDropdownItem } from './toolbar-dropdown'
import MaterialIcon from '../../../../../shared/components/material-icon'
import {
BorderTheme,
insertColumn,
insertRow,
mergeCells,
moveCaption,
removeCaption,
removeColumnWidths,
removeNodes,
removeRowOrColumns,
setAlignment,
setBorders,
unmergeCells,
} from './commands'
import { useCodeMirrorViewContext } from '../../codemirror-context'
import { useTableContext } from '../contexts/table-context'
import { useTabularContext } from '../contexts/tabular-context'
import { useTranslation } from 'react-i18next'
import { FeedbackBadge } from '@/shared/components/feedback-badge'
import classNames from 'classnames'
type CaptionPosition = 'no_caption' | 'above' | 'below'
export const Toolbar = memo(function Toolbar() {
const { selection, setSelection } = useSelectionContext()
const view = useCodeMirrorViewContext()
const {
positions,
rowSeparators,
cellSeparators,
tableEnvironment,
table,
directTableChild,
} = useTableContext()
const { showHelp, openColumnWidthModal } = useTabularContext()
const { t } = useTranslation()
const borderDropdownLabel = useMemo(() => {
switch (table.getBorderTheme()) {
case BorderTheme.FULLY_BORDERED:
return t('all_borders')
case BorderTheme.NO_BORDERS:
return t('no_borders')
default:
return t('custom_borders')
}
}, [table, t])
const captionPosition: CaptionPosition = useMemo(() => {
if (!tableEnvironment?.caption) {
return 'no_caption'
}
if (tableEnvironment.caption.from < positions.tabular.from) {
return 'above'
}
return 'below'
}, [tableEnvironment, positions])
const captionLabel = useMemo(() => {
switch (captionPosition) {
case 'no_caption':
return t('no_caption')
case 'above':
return t('caption_above')
case 'below':
return t('caption_below')
}
}, [t, captionPosition])
const currentAlignment = useMemo(() => {
if (!selection) {
return undefined
}
if (selection.isMergedCellSelected(table)) {
const cell = table.getCell(selection.from.row, selection.from.cell)
if (cell.multiColumn) {
// NOTE: Assumes merged columns can only have one internal column
return cell.multiColumn.columns.specification[0].alignment
}
}
const { minX, maxX } = selection.normalized()
const alignment = table.columns[minX].alignment
for (let x = minX + 1; x <= maxX; x++) {
if (table.columns[x].alignment !== alignment) {
return undefined
}
}
return alignment
}, [selection, table])
const alignmentIcon = useMemo(() => {
switch (currentAlignment) {
case 'left':
return 'format_align_left'
case 'center':
return 'format_align_center'
case 'right':
return 'format_align_right'
case 'paragraph':
return 'format_align_justify'
default:
return 'format_align_left'
}
}, [currentAlignment])
const hasCustomSizes = useMemo(
() => table.columns.some(x => x.size),
[table.columns]
)
if (!selection) {
return null
}
const columnsToInsert = selection.maximumCellWidth(table)
const rowsToInsert = selection.height()
const onlyFixedWidthColumnsSelected = selection.isOnlyFixedWidthColumns(table)
const onlyNonFixedWidthColumnsSelected =
selection.isOnlyNonFixedWidthColumns(table)
return (
<div
className={classNames('table-generator-floating-toolbar', {
'table-generator-toolbar-floating-custom-sizes': hasCustomSizes,
})}
>
<div className="table-generator-button-group">
<ToolbarDropdown
id="table-generator-caption-dropdown"
label={captionLabel}
disabled={!tableEnvironment || !directTableChild}
disabledTooltip={t(
'to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table'
)}
>
<ToolbarDropdownItem
id="table-generator-caption-none"
command={() => {
removeCaption(view, tableEnvironment)
}}
active={captionPosition === 'no_caption'}
>
{t('no_caption')}
</ToolbarDropdownItem>
<ToolbarDropdownItem
id="table-generator-caption-above"
command={() => {
moveCaption(view, positions, 'above', tableEnvironment)
}}
active={captionPosition === 'above'}
>
{t('caption_above')}
</ToolbarDropdownItem>
<ToolbarDropdownItem
id="table-generator-caption-below"
command={() => {
moveCaption(view, positions, 'below', tableEnvironment)
}}
active={captionPosition === 'below'}
>
{t('caption_below')}
</ToolbarDropdownItem>
</ToolbarDropdown>
<ToolbarDropdown
id="table-generator-borders-dropdown"
label={borderDropdownLabel}
>
<ToolbarDropdownItem
id="table-generator-borders-fully-bordered"
command={() => {
setBorders(
view,
BorderTheme.FULLY_BORDERED,
positions,
rowSeparators,
table
)
}}
active={table.getBorderTheme() === BorderTheme.FULLY_BORDERED}
icon="border_all"
>
{t('all_borders')}
</ToolbarDropdownItem>
<ToolbarDropdownItem
id="table-generator-borders-no-borders"
command={() => {
setBorders(
view,
BorderTheme.NO_BORDERS,
positions,
rowSeparators,
table
)
}}
active={table.getBorderTheme() === BorderTheme.NO_BORDERS}
icon="border_clear"
>
{t('no_borders')}
</ToolbarDropdownItem>
<div className="table-generator-border-options-coming-soon">
<div className="info-icon">
<MaterialIcon type="info" />
</div>
{t('more_options_for_border_settings_coming_soon')}
</div>
</ToolbarDropdown>
</div>
<div className="table-generator-button-group">
<ToolbarButtonMenu
label={t('alignment')}
icon={alignmentIcon}
id="table-generator-align-dropdown"
disabledLabel={t('select_a_column_or_a_merged_cell_to_align')}
disabled={
!selection.isColumnSelected(selection.from.cell, table) &&
!selection.isMergedCellSelected(table)
}
>
<ToolbarButton
icon="format_align_left"
id="table-generator-align-left"
label={t('left')}
command={() => {
setAlignment(view, selection, 'left', positions, table)
}}
active={currentAlignment === 'left'}
/>
<ToolbarButton
icon="format_align_center"
id="table-generator-align-center"
label={t('center')}
command={() => {
setAlignment(view, selection, 'center', positions, table)
}}
active={currentAlignment === 'center'}
/>
<ToolbarButton
icon="format_align_right"
id="table-generator-align-right"
label={t('right')}
command={() => {
setAlignment(view, selection, 'right', positions, table)
}}
active={currentAlignment === 'right'}
/>
{onlyFixedWidthColumnsSelected &&
!selection.isMergedCellSelected(table) && (
<ToolbarButton
icon="format_align_justify"
id="table-generator-align-justify"
label={t('justify')}
command={() => {
setAlignment(view, selection, 'paragraph', positions, table)
}}
active={currentAlignment === 'paragraph'}
/>
)}
</ToolbarButtonMenu>
<ToolbarDropdown
id="format_text_wrap"
btnClassName="table-generator-toolbar-button"
icon={
selection.isOnlyParagraphCells(table) ? 'format_text_wrap' : 'width'
}
tooltip={t('adjust_column_width')}
disabled={!selection.isAnyColumnSelected(table)}
disabledTooltip={t('select_a_column_to_adjust_column_width')}
showCaret
>
<ToolbarDropdownItem
id="table-generator-unwrap-text"
icon="width"
active={onlyNonFixedWidthColumnsSelected}
command={() =>
removeColumnWidths(view, selection, positions, table)
}
disabled={!selection.isAnyColumnSelected(table)}
>
{t('stretch_width_to_text')}
</ToolbarDropdownItem>
<ToolbarDropdownItem
id="table-generator-wrap-text"
icon="format_text_wrap"
active={onlyFixedWidthColumnsSelected}
command={openColumnWidthModal}
disabled={!selection.isAnyColumnSelected(table)}
>
{onlyFixedWidthColumnsSelected
? t('fixed_width')
: t('fixed_width_wrap_text')}
</ToolbarDropdownItem>
{onlyFixedWidthColumnsSelected && (
<>
<hr />
<ToolbarDropdownItem
id="table-generator-resize"
command={openColumnWidthModal}
>
{t('set_column_width')}
</ToolbarDropdownItem>
</>
)}
</ToolbarDropdown>
<ToolbarButton
icon="cell_merge"
id="table-generator-merge-cells"
label={
selection.isMergedCellSelected(table)
? t('unmerge_cells')
: t('merge_cells')
}
active={selection.isMergedCellSelected(table)}
disabled={
!selection.isMergedCellSelected(table) &&
!selection.isMergeableCells(table)
}
disabledLabel={t('select_cells_in_a_single_row_to_merge')}
command={() => {
if (selection.isMergedCellSelected(table)) {
unmergeCells(view, selection, table)
} else {
mergeCells(view, selection, table)
}
}}
/>
<ToolbarButton
icon="delete"
id="table-generator-remove-column-row"
label={t('delete_row_or_column')}
disabledLabel={t('select_a_row_or_a_column_to_delete')}
disabled={
(!selection.isAnyRowSelected(table) &&
!selection.isAnyColumnSelected(table)) ||
!selection.eq(selection.explode(table))
}
command={() =>
setSelection(
removeRowOrColumns(
view,
selection,
positions,
cellSeparators,
table
)
)
}
/>
<ToolbarDropdown
id="table-generator-add-dropdown"
btnClassName="table-generator-toolbar-button"
icon="add"
tooltip={t('insert')}
disabled={!selection}
>
<ToolbarDropdownItem
id="table-generator-insert-column-left"
command={() => {
setSelection(
insertColumn(view, selection, positions, false, table)
)
}}
>
<span className="table-generator-button-label">
{columnsToInsert === 1
? t('insert_column_left')
: t('insert_x_columns_left', { columns: columnsToInsert })}
</span>
</ToolbarDropdownItem>
<ToolbarDropdownItem
id="table-generator-insert-column-right"
command={() => {
setSelection(
insertColumn(view, selection, positions, true, table)
)
}}
>
<span className="table-generator-button-label">
{columnsToInsert === 1
? t('insert_column_right')
: t('insert_x_columns_right', { columns: columnsToInsert })}
</span>
</ToolbarDropdownItem>
<hr />
<ToolbarDropdownItem
id="table-generator-insert-row-above"
command={() => {
setSelection(
insertRow(
view,
selection,
positions,
false,
rowSeparators,
table
)
)
}}
>
<span className="table-generator-button-label">
{rowsToInsert === 1
? t('insert_row_above')
: t('insert_x_rows_above', { rows: rowsToInsert })}
</span>
</ToolbarDropdownItem>
<ToolbarDropdownItem
id="table-generator-insert-row-below"
command={() => {
setSelection(
insertRow(
view,
selection,
positions,
true,
rowSeparators,
table
)
)
}}
>
<span className="table-generator-button-label">
{rowsToInsert === 1
? t('insert_row_below')
: t('insert_x_rows_below', { rows: rowsToInsert })}
</span>
</ToolbarDropdownItem>
</ToolbarDropdown>
</div>
<div className="table-generator-button-group">
<ToolbarButton
icon="delete_forever"
id="table-generator-remove-table"
label={t('delete_table')}
command={() => {
removeNodes(view, tableEnvironment?.table ?? positions.tabular)
view.focus()
}}
/>
<ToolbarButton
icon="help"
id="table-generator-show-help"
label={t('help')}
command={showHelp}
/>
<div className="toolbar-beta-badge">
<FeedbackBadge
id="table-generator-feedback"
url="https://forms.gle/9dHxXPGugxEHgY3L9"
text={<FeedbackBadgeContent />}
/>
</div>
</div>
</div>
)
})
const FeedbackBadgeContent = () => (
<>
We have a new way to insert and edit tables.
<br />
Click to give feedback
</>
)

View File

@@ -0,0 +1,557 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { CellData, ColumnDefinition, TableData } from './tabular'
import { TableEnvironmentData } from './contexts/table-context'
import {
ABSOLUTE_SIZE_REGEX,
AbsoluteWidthUnits,
RELATIVE_SIZE_REGEX,
RelativeWidthCommand,
WidthSelection,
} from './toolbar/column-width-modal/column-width'
const COMMIT_CHARACTERS = ['c', 'l', 'r', 'p', 'm', 'b', '>']
export type CellPosition = { from: number; to: number }
export type RowPosition = {
from: number
to: number
hlines: { from: number; to: number }[]
}
function parseArgument(spec: string, startIndex: number): number {
if (spec.charAt(startIndex) !== '{') {
throw new Error('Missing opening brace')
}
let depth = 0
for (let i = startIndex; i < spec.length; i++) {
if (spec.charAt(i) === '{') {
depth++
} else if (spec.charAt(i) === '}') {
depth--
}
if (depth === 0) {
return i
}
}
throw new Error('Missing closing brace')
}
export function parseColumnSpecifications(
specification: string
): ColumnDefinition[] {
const columns: ColumnDefinition[] = []
let currentAlignment: ColumnDefinition['alignment'] | undefined
let currentBorderLeft = 0
let currentBorderRight = 0
let currentContent = ''
let currentCellSpacingLeft = ''
let currentCellSpacingRight = ''
let currentCustomCellDefinition = ''
let currentIsParagraphColumn = false
let currentSize: WidthSelection | undefined
function maybeCommit() {
if (currentAlignment !== undefined) {
columns.push({
alignment: currentAlignment,
borderLeft: currentBorderLeft,
borderRight: currentBorderRight,
content: currentContent,
cellSpacingLeft: currentCellSpacingLeft,
cellSpacingRight: currentCellSpacingRight,
customCellDefinition: currentCustomCellDefinition,
isParagraphColumn: currentIsParagraphColumn,
size: currentSize,
})
currentAlignment = undefined
currentBorderLeft = 0
currentBorderRight = 0
currentContent = ''
currentCellSpacingLeft = ''
currentCellSpacingRight = ''
currentCustomCellDefinition = ''
currentIsParagraphColumn = false
currentSize = undefined
}
}
for (let i = 0; i < specification.length; i++) {
if (COMMIT_CHARACTERS.includes(specification.charAt(i))) {
maybeCommit()
}
const hasAlignment = currentAlignment !== undefined
const char = specification.charAt(i)
switch (char) {
case '|': {
if (hasAlignment) {
currentBorderRight++
} else {
currentBorderLeft++
}
break
}
case 'c':
currentAlignment = 'center'
currentContent += 'c'
break
case 'l':
currentAlignment = 'left'
currentContent += 'l'
break
case 'r':
currentAlignment = 'right'
currentContent += 'r'
break
case 'p':
case 'm':
case 'b': {
currentIsParagraphColumn = true
currentAlignment = 'paragraph'
if (currentCustomCellDefinition !== '') {
// Maybe we have another alignment hidden in here
const match = currentCustomCellDefinition.match(
/>\{\s*\\(raggedleft|raggedright|centering)\s*\\arraybackslash\s*\}/
)
if (match) {
switch (match[1]) {
case 'raggedleft':
currentAlignment = 'right'
break
case 'raggedright':
currentAlignment = 'left'
break
case 'centering':
currentAlignment = 'center'
break
}
}
}
currentContent += char
const argumentEnd = parseArgument(specification, i + 1)
const columnDefinition = specification.slice(i, argumentEnd + 1)
const absoluteSizeMatch = columnDefinition.match(ABSOLUTE_SIZE_REGEX)
const relativeSizeMatch = columnDefinition.match(RELATIVE_SIZE_REGEX)
if (absoluteSizeMatch) {
currentSize = {
unit: absoluteSizeMatch[2] as AbsoluteWidthUnits,
width: parseFloat(absoluteSizeMatch[1]),
}
} else if (relativeSizeMatch) {
const widthAsFraction = parseFloat(relativeSizeMatch[1]) || 0
currentSize = {
unit: '%',
width: widthAsFraction * 100,
command: relativeSizeMatch[2] as RelativeWidthCommand,
}
} else {
currentSize = {
unit: 'custom',
width: columnDefinition.slice(2, -1),
}
}
// Don't include the p twice
currentContent += columnDefinition.slice(1)
i = argumentEnd
break
}
case '@':
case '!': {
const argumentEnd = parseArgument(specification, i + 1)
// Include the @/!
const argument = specification.slice(i, argumentEnd + 1)
i = argumentEnd
if (currentAlignment) {
// We have a cell, so this is right cell spacing
currentCellSpacingRight = argument
} else {
currentCellSpacingLeft = argument
}
break
}
case '>': {
const argumentEnd = parseArgument(specification, i + 1)
// Include the >
const argument = specification.slice(i, argumentEnd + 1)
i = argumentEnd
currentCustomCellDefinition = argument
break
}
case ' ':
case '\n':
case '\t':
currentContent += char
break
}
}
maybeCommit()
return columns
}
const isRowSeparator = (node: SyntaxNode) =>
node.type.is('Command') &&
Boolean(node.getChild('KnownCtrlSym')?.getChild('LineBreak'))
const isHLine = (node: SyntaxNode) =>
node.type.is('Command') &&
Boolean(node.getChild('KnownCommand')?.getChild('HorizontalLine'))
const isMultiColumn = (node: SyntaxNode) =>
node.type.is('Command') &&
Boolean(node.getChild('KnownCommand')?.getChild('MultiColumn'))
type Position = {
from: number
to: number
}
type HLineData = {
position: Position
atBottom: boolean
}
type ParsedCell = {
content: string
position: Position
multiColumn?: {
columnSpecification: {
position: Position
specification: ColumnDefinition[]
}
span: number
position: Position
preamble: Position
postamble: Position
}
}
export type CellSeparator = Position
export type RowSeparator = Position
type ParsedRow = {
position: Position
cells: ParsedCell[]
cellSeparators: CellSeparator[]
hlines: HLineData[]
}
type ParsedTableBody = {
rows: ParsedRow[]
rowSeparators: RowSeparator[]
}
function parseTabularBody(
node: SyntaxNode,
state: EditorState
): ParsedTableBody {
const firstChild = node.firstChild
const body: ParsedTableBody = {
rows: [
{
cells: [],
hlines: [],
cellSeparators: [],
position: { from: node.from, to: node.from },
},
],
rowSeparators: [],
}
getLastRow().cells.push({
content: '',
position: { from: node.from, to: node.from },
})
function getLastRow() {
return body.rows[body.rows.length - 1]
}
function getLastCell(): ParsedCell | undefined {
return getLastRow().cells[getLastRow().cells.length - 1]
}
for (
let currentChild: SyntaxNode | null = firstChild;
currentChild;
currentChild = currentChild.nextSibling
) {
if (isRowSeparator(currentChild)) {
const lastRow = getLastRow()
body.rows.push({
cells: [],
hlines: [],
cellSeparators: [],
position: { from: currentChild.to, to: currentChild.to },
})
lastRow.position.to = currentChild.to
body.rowSeparators.push({ from: currentChild.from, to: currentChild.to })
getLastRow().cells.push({
content: '',
position: { from: currentChild.to, to: currentChild.to },
})
continue
} else if (currentChild.type.is('Ampersand')) {
// Add another cell
getLastRow().cells.push({
content: '',
position: { from: currentChild.to, to: currentChild.to },
})
getLastRow().cellSeparators.push({
from: currentChild.from,
to: currentChild.to,
})
} else if (isMultiColumn(currentChild)) {
// do stuff
const multiColumn = currentChild
.getChild('KnownCommand')!
.getChild('MultiColumn')!
const columnArgument = multiColumn
.getChild('ColumnArgument')
?.getChild('ShortTextArgument')
?.getChild('ShortArg')
const spanArgument = multiColumn
.getChild('SpanArgument')
?.getChild('ShortTextArgument')
?.getChild('ShortArg')
const tabularArgument = multiColumn
.getChild('TabularArgument')
?.getChild('TabularContent')
if (!columnArgument) {
throw new Error(
'Invalid multicolumn definition: missing column specification argument'
)
}
if (!spanArgument) {
throw new Error(
'Invalid multicolumn definition: missing colspan argument'
)
}
if (!tabularArgument) {
throw new Error('Invalid multicolumn definition: missing cell content')
}
if (getLastCell()?.content.trim()) {
throw new Error(
'Invalid multicolumn definition: multicolumn must be at the start of a cell'
)
}
const columnSpecification = parseColumnSpecifications(
state.sliceDoc(columnArgument.from, columnArgument.to)
)
const span = parseInt(state.sliceDoc(spanArgument.from, spanArgument.to))
const cellContent = state.sliceDoc(
tabularArgument.from,
tabularArgument.to
)
if (!getLastCell()) {
getLastRow().cells.push({
content: '',
position: { from: currentChild.from, to: currentChild.from },
})
}
const lastCell = getLastCell()!
lastCell.multiColumn = {
columnSpecification: {
position: { from: columnArgument.from, to: columnArgument.to },
specification: columnSpecification,
},
span,
preamble: {
from: currentChild.from,
to: tabularArgument.from,
},
postamble: {
from: tabularArgument.to,
to: currentChild.to,
},
position: { from: currentChild.from, to: currentChild.to },
}
lastCell.content = cellContent
lastCell.position.from = tabularArgument.from
lastCell.position.to = tabularArgument.to
// Don't update position at the end of the loop
continue
} else if (
currentChild.type.is('NewLine') ||
currentChild.type.is('Whitespace') ||
currentChild.type.is('Comment') ||
currentChild.type.is('BlankLine')
) {
const lastCell = getLastCell()
if (!lastCell?.multiColumn) {
if (lastCell) {
if (lastCell.content.trim() === '') {
lastCell.position.from = currentChild.to
lastCell.position.to = currentChild.to
} else {
lastCell.content += state.sliceDoc(
currentChild.from,
currentChild.to
)
lastCell.position.to = currentChild.to
}
}
}
// Try to preserve whitespace by skipping past it when locating cells
} else if (isHLine(currentChild)) {
const lastCell = getLastCell()
if (lastCell?.content.trim()) {
throw new Error('\\hline must be at the start of a row')
}
// push start of cell past the hline
if (lastCell) {
lastCell.position.from = currentChild.to
lastCell.position.to = currentChild.to
}
const lastRow = getLastRow()
lastRow.hlines.push({
position: { from: currentChild.from, to: currentChild.to },
// They will always be at the top, we patch the bottom border later.
atBottom: false,
})
} else {
// Add to the last cell
if (!getLastCell()) {
getLastRow().cells.push({
content: '',
position: { from: currentChild.from, to: currentChild.from },
})
}
const lastCell = getLastCell()!
lastCell.content += state.sliceDoc(currentChild.from, currentChild.to)
lastCell.position.to = currentChild.to
}
getLastRow().position.to = currentChild.to
}
const lastRow = getLastRow()
if (
body.rows.length > 1 &&
lastRow.cells.length === 1 &&
lastRow.cells[0].content.trim() === ''
) {
// Remove the last row if it's empty, but move hlines up to previous row
const hlines = lastRow.hlines.map(hline => ({ ...hline, atBottom: true }))
body.rows.pop()
getLastRow().hlines.push(...hlines)
const lastLineContents = state.sliceDoc(
lastRow.position.from,
lastRow.position.to
)
const lastLineOffset =
lastLineContents.length - lastLineContents.trimEnd().length
getLastRow().position.to = lastRow.position.to - lastLineOffset
}
return body
}
export type ParsedTableData = {
table: TableData
cellPositions: CellPosition[][]
specification: { from: number; to: number }
rowPositions: RowPosition[]
rowSeparators: RowSeparator[]
cellSeparators: CellSeparator[][]
}
export function generateTable(
node: SyntaxNode,
state: EditorState
): ParsedTableData {
const specification = node
.getChild('BeginEnv')
?.getChild('TextArgument')
?.getChild('LongArg')
if (!specification) {
throw new Error('Missing column specification')
}
const columns = parseColumnSpecifications(
state.sliceDoc(specification.from, specification.to)
)
const body = node.getChild('Content')?.getChild('TabularContent')
if (!body) {
throw new Error('Missing table body')
}
const tableData = parseTabularBody(body, state)
const cellPositions = tableData.rows.map(row =>
row.cells.map(cell => cell.multiColumn?.position ?? cell.position)
)
const cellSeparators = tableData.rows.map(row => row.cellSeparators)
const rowPositions = tableData.rows.map(row => ({
...row.position,
hlines: row.hlines.map(hline => hline.position),
}))
const rows = tableData.rows.map(row => ({
cells: row.cells.map(cell => {
const cellData: CellData = {
content: cell.content,
from: cell.position.from,
to: cell.position.to,
}
if (cell.multiColumn) {
cellData.multiColumn = {
columns: {
specification: cell.multiColumn.columnSpecification.specification,
from: cell.multiColumn.columnSpecification.position.from,
to: cell.multiColumn.columnSpecification.position.to,
},
columnSpan: cell.multiColumn.span,
from: cell.multiColumn.position.from,
to: cell.multiColumn.position.to,
preamble: {
from: cell.multiColumn.preamble.from,
to: cell.multiColumn.preamble.to,
},
postamble: {
from: cell.multiColumn.postamble.from,
to: cell.multiColumn.postamble.to,
},
}
}
return cellData
}),
borderTop: row.hlines.filter(hline => !hline.atBottom).length,
borderBottom: row.hlines.filter(hline => hline.atBottom).length,
}))
const table = new TableData(rows, columns)
return {
table,
cellPositions,
specification,
rowPositions,
rowSeparators: tableData.rowSeparators,
cellSeparators,
}
}
export const validateParsedTable = (parseResult: ParsedTableData) => {
for (const row of parseResult.table.rows) {
const rowLength = row.cells.reduce(
(acc, cell) => acc + (cell.multiColumn?.columnSpan ?? 1),
0
)
for (const cell of row.cells) {
if (
cell.multiColumn?.columns.specification &&
cell.multiColumn.columns.specification.length !== 1
) {
return false
}
}
if (rowLength !== parseResult.table.columns.length) {
return false
}
}
return true
}
export function parseTableEnvironment(tableNode: SyntaxNode) {
const tableEnvironment: TableEnvironmentData = {
table: { from: tableNode.from, to: tableNode.to },
}
tableNode.cursor().iterate(({ type, from, to }) => {
if (tableEnvironment.caption && tableEnvironment.label) {
// Stop looking once we've found both caption and label
return false
}
if (type.is('Caption')) {
tableEnvironment.caption = { from, to }
} else if (type.is('Label')) {
tableEnvironment.label = { from, to }
}
})
return tableEnvironment
}

View File

@@ -0,0 +1,96 @@
import { FC, memo, useRef } from 'react'
import useDropdown from '../../../../shared/hooks/use-dropdown'
import OLListGroup from '@/features/ui/components/ol/ol-list-group'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import { EditorView } from '@codemirror/view'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
import { useCodeMirrorViewContext } from '../codemirror-context'
export const ToolbarButtonMenu: FC<{
id: string
label: string
icon: React.ReactNode
altCommand?: (view: EditorView) => void
}> = memo(function ButtonMenu({ icon, id, label, altCommand, children }) {
const target = useRef<any>(null)
const { open, onToggle, ref } = useDropdown()
const view = useCodeMirrorViewContext()
const button = (
<button
type="button"
className="ol-cm-toolbar-button btn"
aria-label={label}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={event => {
if (event.altKey && altCommand && open === false) {
emitToolbarEvent(view, id)
event.preventDefault()
altCommand(view)
view.focus()
} else {
onToggle(!open)
}
}}
ref={target}
>
{icon}
</button>
)
const overlay = (
<OLOverlay
show={open}
target={target.current}
placement="bottom"
container={view.dom}
containerPadding={0}
transition
rootClose
onHide={() => onToggle(false)}
>
<OLPopover
id={`${id}-menu`}
ref={ref}
className="ol-cm-toolbar-button-menu-popover"
>
<OLListGroup
role="menu"
onClick={() => {
onToggle(false)
}}
>
{children}
</OLListGroup>
</OLPopover>
</OLOverlay>
)
if (!label) {
return (
<>
{button}
{overlay}
</>
)
}
return (
<>
<OLTooltip
hidden={open}
id={id}
description={<div>{label}</div>}
overlayProps={{ placement: 'bottom' }}
>
{button}
</OLTooltip>
{overlay}
</>
)
})

View File

@@ -0,0 +1,93 @@
import { ToolbarButtonMenu } from './button-menu'
import MaterialIcon from '@/shared/components/material-icon'
import OLListGroupItem from '@/features/ui/components/ol/ol-list-group-item'
import { memo, useCallback } from 'react'
import { FigureModalSource } from '../figure-modal/figure-modal-context'
import { useTranslation } from 'react-i18next'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
import { useCodeMirrorViewContext } from '../codemirror-context'
import { insertFigure } from '../../extensions/toolbar/commands'
import getMeta from '@/utils/meta'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { ToolbarButton } from './toolbar-button'
export const InsertFigureDropdown = memo(function InsertFigureDropdown() {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { write } = usePermissionsContext()
const openFigureModal = useCallback(
(source: FigureModalSource, sourceName: string) => {
emitToolbarEvent(view, `toolbar-figure-modal-${sourceName}`)
window.dispatchEvent(
new CustomEvent('figure-modal:open', {
detail: { source },
})
)
},
[view]
)
const {
hasLinkedProjectFileFeature,
hasLinkedProjectOutputFileFeature,
hasLinkUrlFeature,
} = getMeta('ol-ExposedSettings')
if (!write) {
return (
<ToolbarButton
id="toolbar-figure"
label={t('toolbar_insert_figure')}
command={() =>
openFigureModal(FigureModalSource.FILE_TREE, 'current-project')
}
icon="add_photo_alternate"
/>
)
}
return (
<ToolbarButtonMenu
id="toolbar-figure"
label={t('toolbar_insert_figure')}
icon={<MaterialIcon type="add_photo_alternate" />}
altCommand={insertFigure}
>
<OLListGroupItem
onClick={() =>
openFigureModal(FigureModalSource.FILE_UPLOAD, 'file-upload')
}
>
<MaterialIcon type="upload" />
{t('upload_from_computer')}
</OLListGroupItem>
<OLListGroupItem
onClick={() =>
openFigureModal(FigureModalSource.FILE_TREE, 'current-project')
}
>
<MaterialIcon type="inbox" />
{t('from_project_files')}
</OLListGroupItem>
{(hasLinkedProjectFileFeature || hasLinkedProjectOutputFileFeature) && (
<OLListGroupItem
onClick={() =>
openFigureModal(FigureModalSource.OTHER_PROJECT, 'other-project')
}
>
<MaterialIcon type="folder_open" />
{t('from_another_project')}
</OLListGroupItem>
)}
{hasLinkUrlFeature && (
<OLListGroupItem
onClick={() =>
openFigureModal(FigureModalSource.FROM_URL, 'from-url')
}
>
<MaterialIcon type="public" />
{t('from_url')}
</OLListGroupItem>
)}
</ToolbarButtonMenu>
)
})

View File

@@ -0,0 +1,83 @@
import { DropdownHeader } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { ToolbarButtonMenu } from './button-menu'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
import MaterialIcon from '../../../../shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import { useCodeMirrorViewContext } from '../codemirror-context'
import { useEditorContext } from '@/shared/context/editor-context'
import {
wrapInDisplayMath,
wrapInInlineMath,
} from '../../extensions/toolbar/commands'
import { memo } from 'react'
import OLListGroupItem from '@/features/ui/components/ol/ol-list-group-item'
import sparkleWhite from '@/shared/svgs/sparkle-small-white.svg'
import sparkle from '@/shared/svgs/ai-sparkle-text.svg'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export const MathDropdown = memo(function MathDropdown() {
const { t } = useTranslation()
const view = useCodeMirrorViewContext()
const { writefullInstance } = useEditorContext()
const wfRebrandEnabled = isSplitTestEnabled('wf-feature-rebrand')
return (
<ToolbarButtonMenu
id="toolbar-math"
label={t('toolbar_insert_math')}
icon={<MaterialIcon type="calculate" />}
>
{wfRebrandEnabled && writefullInstance && (
<>
<DropdownHeader className="ol-cm-toolbar-header mx-2">
{t('toolbar_insert_math_lowercase')}
</DropdownHeader>
<OLListGroupItem
aria-label={t('toolbar_generate_math')}
onClick={event => {
writefullInstance?.openEquationGenerator()
}}
>
<img
alt="sparkle"
className="ol-cm-toolbar-ai-sparkle-gradient"
src={sparkle}
aria-hidden="true"
/>
<img
alt="sparkle"
className="ol-cm-toolbar-ai-sparkle-white"
src={sparkleWhite}
aria-hidden="true"
/>
<span>{t('generate_from_text_or_image')}</span>
</OLListGroupItem>
</>
)}
<OLListGroupItem
aria-label={t('toolbar_insert_inline_math')}
onClick={event => {
emitToolbarEvent(view, 'toolbar-inline-math')
event.preventDefault()
wrapInInlineMath(view)
view.focus()
}}
>
<MaterialIcon type="123" />
<span>{t('inline')}</span>
</OLListGroupItem>
<OLListGroupItem
aria-label={t('toolbar_insert_display_math')}
onClick={event => {
emitToolbarEvent(view, 'toolbar-display-math')
event.preventDefault()
wrapInDisplayMath(view)
view.focus()
}}
>
<MaterialIcon type="view_day" />
<span>{t('display')}</span>
</OLListGroupItem>
</ToolbarButtonMenu>
)
})

View File

@@ -0,0 +1,60 @@
import { FC, useRef } from 'react'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import { useCodeMirrorViewContext } from '../codemirror-context'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
export const ToolbarOverflow: FC<{
overflowed: boolean
overflowOpen: boolean
setOverflowOpen: (open: boolean) => void
overflowRef?: React.Ref<HTMLDivElement>
}> = ({ overflowed, overflowOpen, setOverflowOpen, overflowRef, children }) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const view = useCodeMirrorViewContext()
const className = classnames(
'ol-cm-toolbar-button',
'ol-cm-toolbar-overflow-toggle',
{
'ol-cm-toolbar-overflow-toggle-visible': overflowed,
}
)
return (
<>
<button
ref={buttonRef}
type="button"
id="toolbar-more"
className={className}
aria-label="More"
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
setOverflowOpen(!overflowOpen)
}}
>
<MaterialIcon type="more_horiz" />
</button>
<OLOverlay
show={overflowOpen}
target={buttonRef.current}
placement="bottom"
container={view.dom}
// containerPadding={0}
transition
rootClose
onHide={() => setOverflowOpen(false)}
>
<OLPopover id="popover-toolbar-overflow" ref={overflowRef}>
<div className="ol-cm-toolbar-overflow">{children}</div>
</OLPopover>
</OLOverlay>
</>
)
}

View File

@@ -0,0 +1,132 @@
import classnames from 'classnames'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '../codemirror-context'
import {
findCurrentSectionHeadingLevel,
setSectionHeadingLevel,
} from '../../extensions/toolbar/sections'
import { useCallback, useMemo, useRef } from 'react'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import useEventListener from '../../../../shared/hooks/use-event-listener'
import useDropdown from '../../../../shared/hooks/use-dropdown'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
import Icon from '../../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
const levels = new Map([
['text', 'Normal text'],
['section', 'Section'],
['subsection', 'Subsection'],
['subsubsection', 'Subsubsection'],
['paragraph', 'Paragraph'],
['subparagraph', 'Subparagraph'],
])
const levelsEntries = [...levels.entries()]
export const SectionHeadingDropdown = () => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const { t } = useTranslation()
const { open: overflowOpen, onToggle: setOverflowOpen } = useDropdown()
useEventListener(
'resize',
useCallback(() => {
setOverflowOpen(false)
}, [setOverflowOpen])
)
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
const currentLevel = useMemo(
() => findCurrentSectionHeadingLevel(state),
[state]
)
const currentLabel = currentLevel
? (levels.get(currentLevel.level) ?? currentLevel.level)
: '---'
return (
<>
<button
ref={toggleButtonRef}
type="button"
id="section-heading-menu-button"
aria-haspopup="true"
aria-controls="section-heading-menu"
aria-label={t('toolbar_choose_section_heading_level')}
className="ol-cm-toolbar-menu-toggle"
onMouseDown={event => event.preventDefault()}
onClick={() => setOverflowOpen(!overflowOpen)}
>
<span>{currentLabel}</span>
<Icon type="caret-down" fw />
</button>
{overflowOpen && (
<OLOverlay
show
onHide={() => setOverflowOpen(false)}
transition={false}
container={view.dom}
containerPadding={0}
placement="bottom"
rootClose
target={toggleButtonRef.current}
popperConfig={{
modifiers: [
{
name: 'offset',
options: {
offset: [0, 1],
},
},
],
}}
>
<OLPopover
id="popover-toolbar-section-heading"
className="ol-cm-toolbar-menu-popover"
>
<div
className="ol-cm-toolbar-menu"
id="section-heading-menu"
role="menu"
aria-labelledby="section-heading-menu-button"
>
{levelsEntries.map(([level, label]) => (
<button
type="button"
role="menuitem"
key={level}
onClick={() => {
emitToolbarEvent(view, 'section-level-change')
setSectionHeadingLevel(view, level)
view.focus()
setOverflowOpen(false)
}}
className={classnames(
'ol-cm-toolbar-menu-item',
`section-level-${level}`,
{
'ol-cm-toolbar-menu-item-active':
level === currentLevel?.level,
}
)}
>
{label}
</button>
))}
</div>
</OLPopover>
</OLOverlay>
)}
</>
)
}

View File

@@ -0,0 +1,101 @@
import { DropdownHeader } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { ToolbarButtonMenu } from './button-menu'
import MaterialIcon from '../../../../shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import { useEditorContext } from '@/shared/context/editor-context'
import { memo, useRef, useCallback } from 'react'
import OLListGroupItem from '@/features/ui/components/ol/ol-list-group-item'
import sparkleWhite from '@/shared/svgs/sparkle-small-white.svg'
import sparkle from '@/shared/svgs/ai-sparkle-text.svg'
import { TableInserterDropdown } from './table-inserter-dropdown'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import useDropdown from '../../../../shared/hooks/use-dropdown'
import * as commands from '../../extensions/toolbar/commands'
import { useCodeMirrorViewContext } from '../codemirror-context'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
export const TableDropdown = memo(function TableDropdown() {
const { t } = useTranslation()
const { writefullInstance } = useEditorContext()
const { open, onToggle, ref } = useDropdown()
const target = useRef<any>(null)
const view = useCodeMirrorViewContext()
const onSizeSelected = useCallback(
(sizeX: number, sizeY: number) => {
onToggle(false)
commands.insertTable(view, sizeX, sizeY)
emitToolbarEvent(view, 'table-generator-insert-table')
view.focus()
},
[view, onToggle]
)
return (
<>
<div ref={target}>
<ToolbarButtonMenu
id="toolbar-table"
label={t('toolbar_insert_table')}
icon={<MaterialIcon type="table_chart" />}
>
<DropdownHeader className="ol-cm-toolbar-header mx-2">
{t('toolbar_table_insert_table_lowercase')}
</DropdownHeader>
<OLListGroupItem
aria-label={t('toolbar_generate_table')}
onClick={event => {
writefullInstance?.openTableGenerator()
}}
>
<img
alt="sparkle"
className="ol-cm-toolbar-ai-sparkle-gradient"
src={sparkle}
aria-hidden="true"
/>
<img
alt="sparkle"
className="ol-cm-toolbar-ai-sparkle-white"
src={sparkleWhite}
aria-hidden="true"
/>
<span>{t('generate_from_text_or_image')}</span>
</OLListGroupItem>
<div className="ol-cm-toolbar-dropdown-divider mx-2 my-0" />
<OLListGroupItem
aria-label={t('toolbar_insert_table')}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
onToggle(!open)
}}
>
<span>{t('select_size')}</span>
</OLListGroupItem>
</ToolbarButtonMenu>
<OLOverlay
show={open}
target={target.current}
placement="bottom"
container={view.dom}
containerPadding={0}
transition
rootClose
onHide={() => onToggle(false)}
>
<OLPopover
id="toolbar-table-menu"
ref={ref}
className="ol-cm-toolbar-button-menu-popover ol-cm-toolbar-button-menu-popover-unstyled"
>
<TableInserterDropdown onSizeSelected={onSizeSelected} />
</OLPopover>
</OLOverlay>
</div>
</>
)
})

View File

@@ -0,0 +1,128 @@
import { FC, memo, useCallback, useRef, useState } from 'react'
import * as commands from '../../extensions/toolbar/commands'
import { useTranslation } from 'react-i18next'
import useDropdown from '../../../../shared/hooks/use-dropdown'
import { useCodeMirrorViewContext } from '../codemirror-context'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import MaterialIcon from '../../../../shared/components/material-icon'
import classNames from 'classnames'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
export const LegacyTableDropdown = memo(() => {
const { t } = useTranslation()
const { open, onToggle, ref } = useDropdown()
const view = useCodeMirrorViewContext()
const target = useRef<any>(null)
const onSizeSelected = useCallback(
(sizeX: number, sizeY: number) => {
onToggle(false)
commands.insertTable(view, sizeX, sizeY)
emitToolbarEvent(view, 'table-generator-insert-table')
view.focus()
},
[view, onToggle]
)
return (
<>
<OLTooltip
hidden={open}
id="toolbar-table"
description={<div>{t('toolbar_insert_table')}</div>}
overlayProps={{ placement: 'bottom' }}
>
<button
type="button"
className="ol-cm-toolbar-button btn"
aria-label={t('toolbar_insert_table')}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
onToggle(!open)
}}
ref={target}
>
<MaterialIcon type="table_chart" />
</button>
</OLTooltip>
<OLOverlay
show={open}
target={target.current}
placement="bottom"
container={view.dom}
containerPadding={0}
transition
rootClose
onHide={() => onToggle(false)}
>
<OLPopover
id="toolbar-table-menu"
ref={ref}
className="ol-cm-toolbar-button-menu-popover ol-cm-toolbar-button-menu-popover-unstyled"
>
<div className="ol-cm-toolbar-table-grid-popover">
<SizeGrid sizeX={10} sizeY={10} onSizeSelected={onSizeSelected} />
</div>
</OLPopover>
</OLOverlay>
</>
)
})
LegacyTableDropdown.displayName = 'TableInserterDropdown'
const range = (start: number, end: number) =>
Array.from({ length: end - start + 1 }, (v, k) => k + start)
const SizeGrid: FC<{
sizeX: number
sizeY: number
onSizeSelected: (sizeX: number, sizeY: number) => void
}> = ({ sizeX, sizeY, onSizeSelected }) => {
const [currentSize, setCurrentSize] = useState<{
sizeX: number
sizeY: number
}>({ sizeX: 0, sizeY: 0 })
const { t } = useTranslation()
let label = t('toolbar_table_insert_table_lowercase')
if (currentSize.sizeX > 0 && currentSize.sizeY > 0) {
label = t('toolbar_table_insert_size_table', {
size: `${currentSize.sizeY}×${currentSize.sizeX}`,
})
}
return (
<>
<div className="ol-cm-toolbar-table-size-label">{label}</div>
<table
className="ol-cm-toolbar-table-grid"
onMouseLeave={() => {
setCurrentSize({ sizeX: 0, sizeY: 0 })
}}
>
<tbody>
{range(1, sizeY).map(y => (
<tr key={y}>
{range(1, sizeX).map(x => (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
className={classNames('ol-cm-toolbar-table-cell', {
active: currentSize.sizeX >= x && currentSize.sizeY >= y,
})}
key={x}
onMouseEnter={() => {
setCurrentSize({ sizeX: x, sizeY: y })
}}
onMouseUp={() => onSizeSelected(x, y)}
/>
))}
</tr>
))}
</tbody>
</table>
</>
)
}

View File

@@ -0,0 +1,68 @@
import { FC, useState } from 'react'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
export const TableInserterDropdown = ({
onSizeSelected,
}: {
onSizeSelected: (sizeX: number, sizeY: number) => void
}) => {
return (
<div className="ol-cm-toolbar-table-grid-popover">
<SizeGrid sizeX={10} sizeY={10} onSizeSelected={onSizeSelected} />
</div>
)
}
TableInserterDropdown.displayName = 'TableInserterDropdown'
const range = (start: number, end: number) =>
Array.from({ length: end - start + 1 }, (v, k) => k + start)
const SizeGrid: FC<{
sizeX: number
sizeY: number
onSizeSelected: (sizeX: number, sizeY: number) => void
}> = ({ sizeX, sizeY, onSizeSelected }) => {
const [currentSize, setCurrentSize] = useState<{
sizeX: number
sizeY: number
}>({ sizeX: 0, sizeY: 0 })
const { t } = useTranslation()
let label = t('toolbar_table_insert_table_lowercase')
if (currentSize.sizeX > 0 && currentSize.sizeY > 0) {
label = t('toolbar_table_insert_size_table', {
size: `${currentSize.sizeY}×${currentSize.sizeX}`,
})
}
return (
<>
<div className="ol-cm-toolbar-table-size-label">{label}</div>
<table
className="ol-cm-toolbar-table-grid"
onMouseLeave={() => {
setCurrentSize({ sizeX: 0, sizeY: 0 })
}}
>
<tbody>
{range(1, sizeY).map(y => (
<tr key={y}>
{range(1, sizeX).map(x => (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
className={classNames('ol-cm-toolbar-table-cell', {
active: currentSize.sizeX >= x && currentSize.sizeY >= y,
})}
key={x}
onMouseEnter={() => {
setCurrentSize({ sizeX: x, sizeY: y })
}}
onMouseUp={() => onSizeSelected(x, y)}
/>
))}
</tr>
))}
</tbody>
</table>
</>
)
}

View File

@@ -0,0 +1,22 @@
import { FC } from 'react'
import * as commands from '@/features/source-editor/extensions/toolbar/commands'
import { searchPanelOpen } from '@codemirror/search'
import { ToolbarButton } from '@/features/source-editor/components/toolbar/toolbar-button'
import { EditorState } from '@codemirror/state'
import { useTranslation } from 'react-i18next'
import { isMac } from '@/shared/utils/os'
export const ToggleSearchButton: FC<{ state: EditorState }> = ({ state }) => {
const { t } = useTranslation()
return (
<ToolbarButton
id="toolbar-toggle-search"
label={t('search_this_file')}
command={commands.toggleSearch}
active={searchPanelOpen(state)}
icon="search"
shortcut={isMac ? '⌘F' : 'Ctrl+F'}
/>
)
}

View File

@@ -0,0 +1,90 @@
import { memo, useCallback } from 'react'
import { EditorView } from '@codemirror/view'
import { useCodeMirrorViewContext } from '../codemirror-context'
import classnames from 'classnames'
import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export const ToolbarButton = memo<{
id: string
className?: string
label: string
command?: (view: EditorView) => void
active?: boolean
disabled?: boolean
icon: string
textIcon?: boolean
hidden?: boolean
shortcut?: string
}>(function ToolbarButton({
id,
className,
label,
command,
active = false,
disabled,
icon,
textIcon = false,
hidden = false,
shortcut,
}) {
const view = useCodeMirrorViewContext()
const handleMouseDown = useCallback(event => {
event.preventDefault()
}, [])
const handleClick = useCallback(
event => {
emitToolbarEvent(view, id)
if (command) {
event.preventDefault()
command(view)
view.focus()
}
},
[command, view, id]
)
const button = (
<button
className={classnames('ol-cm-toolbar-button', className, {
active,
hidden,
})}
aria-label={label}
onMouseDown={handleMouseDown}
onClick={!disabled ? handleClick : undefined}
aria-disabled={disabled}
type="button"
>
{textIcon ? (
icon
) : (
<MaterialIcon type={icon} accessibilityLabel={label} />
)}
</button>
)
if (!label) {
return button
}
const description = (
<>
<div>{label}</div>
{shortcut && <div>{shortcut}</div>}
</>
)
return (
<OLTooltip
id={id}
description={description}
overlayProps={{ placement: 'bottom' }}
>
{button}
</OLTooltip>
)
})

View File

@@ -0,0 +1,197 @@
import { FC, memo } from 'react'
import { EditorState } from '@codemirror/state'
import { useEditorContext } from '../../../../shared/context/editor-context'
import { ToolbarButton } from './toolbar-button'
import { redo, undo } from '@codemirror/commands'
import * as commands from '../../extensions/toolbar/commands'
import { SectionHeadingDropdown } from './section-heading-dropdown'
import getMeta from '../../../../utils/meta'
import { InsertFigureDropdown } from './insert-figure-dropdown'
import { useTranslation } from 'react-i18next'
import { MathDropdown } from './math-dropdown'
import { TableDropdown } from './table-dropdown'
import { LegacyTableDropdown } from './table-inserter-dropdown-legacy'
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { isMac } from '@/shared/utils/os'
export const ToolbarItems: FC<{
state: EditorState
overflowed?: Set<string>
languageName?: string
visual: boolean
listDepth: number
}> = memo(function ToolbarItems({
state,
overflowed,
languageName,
visual,
listDepth,
}) {
const { t } = useTranslation()
const { toggleSymbolPalette, showSymbolPalette, writefullInstance } =
useEditorContext()
const isActive = withinFormattingCommand(state)
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
const showGroup = (group: string) => !overflowed || overflowed.has(group)
const wfRebrandEnabled = isSplitTestEnabled('wf-feature-rebrand')
return (
<>
{showGroup('group-history') && (
<div
className="ol-cm-toolbar-button-group"
aria-label={t('toolbar_undo_redo_actions')}
>
<ToolbarButton
id="toolbar-undo"
label={t('toolbar_undo')}
command={undo}
icon="undo"
shortcut={isMac ? '⌘Z' : 'Ctrl+Z'}
/>
<ToolbarButton
id="toolbar-redo"
label={t('toolbar_redo')}
command={redo}
icon="redo"
shortcut={isMac ? '⇧⌘Z' : 'Ctrl+Y'}
/>
</div>
)}
{languageName === 'latex' && (
<>
{showGroup('group-section') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-section"
aria-label={t('toolbar_text_formatting')}
>
<SectionHeadingDropdown />
</div>
)}
{showGroup('group-format') && (
<div
className="ol-cm-toolbar-button-group"
aria-label={t('toolbar_text_style')}
>
<ToolbarButton
id="toolbar-format-bold"
label={t('toolbar_format_bold')}
command={commands.toggleBold}
active={isActive('\\textbf')}
icon="format_bold"
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
/>
<ToolbarButton
id="toolbar-format-italic"
label={t('toolbar_format_italic')}
command={commands.toggleItalic}
active={isActive('\\textit')}
icon="format_italic"
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
/>
</div>
)}
{showGroup('group-math') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-math"
aria-label={t('toolbar_insert_math_and_symbols')}
>
<MathDropdown />
{symbolPaletteAvailable && (
<ToolbarButton
id="toolbar-toggle-symbol-palette"
label={t('toolbar_toggle_symbol_palette')}
active={showSymbolPalette}
command={toggleSymbolPalette}
icon="Ω"
textIcon
className="ol-cm-toolbar-button-math"
/>
)}
</div>
)}
{showGroup('group-misc') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-misc"
aria-label={t('toolbar_insert_misc')}
>
<ToolbarButton
id="toolbar-href"
label={t('toolbar_insert_link')}
command={commands.wrapInHref}
icon="add_link"
/>
<ToolbarButton
id="toolbar-add-comment"
label={t('add_comment')}
disabled={state.selection.main.empty}
command={commands.addComment}
icon="add_comment"
/>
<ToolbarButton
id="toolbar-ref"
label={t('toolbar_insert_cross_reference')}
command={commands.insertRef}
icon="sell"
/>
<ToolbarButton
id="toolbar-cite"
label={t('toolbar_insert_citation')}
command={commands.insertCite}
icon="book_5"
/>
<InsertFigureDropdown />
{wfRebrandEnabled && writefullInstance ? (
<TableDropdown />
) : (
<LegacyTableDropdown />
)}
</div>
)}
{showGroup('group-list') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-list"
aria-label={t('toolbar_list_indentation')}
>
<ToolbarButton
id="toolbar-bullet-list"
label={t('toolbar_bullet_list')}
command={commands.toggleBulletList}
icon="format_list_bulleted"
/>
<ToolbarButton
id="toolbar-numbered-list"
label={t('toolbar_numbered_list')}
command={commands.toggleNumberedList}
icon="format_list_numbered"
/>
<ToolbarButton
id="toolbar-format-indent-decrease"
label={t('toolbar_decrease_indent')}
command={commands.indentDecrease}
icon="format_indent_decrease"
shortcut={visual ? (isMac ? '⌘[' : 'Ctrl+[') : undefined}
disabled={listDepth < 2}
/>
<ToolbarButton
id="toolbar-format-indent-increase"
label={t('toolbar_increase_indent')}
command={commands.indentIncrease}
icon="format_indent_increase"
shortcut={visual ? (isMac ? '⌘]' : 'Ctrl+]') : undefined}
disabled={listDepth < 1}
/>
</div>
)}
</>
)}
</>
)
})

View File

@@ -0,0 +1,270 @@
import { EditorView, ViewUpdate } from '@codemirror/view'
import { Diagnostic, linter, lintGutter } from '@codemirror/lint'
import {
Compartment,
EditorState,
Extension,
Line,
RangeSet,
RangeValue,
StateEffect,
StateField,
} from '@codemirror/state'
import { Annotation } from '../../../../../types/annotation'
import { debugConsole } from '@/utils/debugging'
import { sendMB } from '@/infrastructure/event-tracking'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { syntaxTree } from '@codemirror/language'
interface CompileLogDiagnostic extends Diagnostic {
compile?: true
ruleId?: string
id?: string
entryIndex: number
firstOnLine?: boolean
}
type RenderedDiagnostic = Pick<
CompileLogDiagnostic,
| 'message'
| 'severity'
| 'ruleId'
| 'compile'
| 'source'
| 'id'
| 'firstOnLine'
>
export type DiagnosticAction = (
diagnostic: RenderedDiagnostic
) => HTMLButtonElement | null
const diagnosticActions = importOverleafModules('diagnosticActions') as {
import: { default: DiagnosticAction }
}[]
const compileLintSourceConf = new Compartment()
export const annotations = () => [
compileDiagnosticsState,
compileLintSourceConf.of(compileLogLintSource()),
/**
* The built-in lint gutter extension, configured with zero hover delay.
*/
lintGutter({
hoverTime: 0,
}),
annotationsTheme,
]
/**
* A theme which moves the lint gutter outside the line numbers.
*/
const annotationsTheme = EditorView.baseTheme({
'.cm-gutter-lint': {
order: -1,
},
})
export const lintSourceConfig = {
delay: 100,
// Show highlights only for errors
markerFilter(diagnostics: readonly Diagnostic[]) {
return diagnostics.filter(d => d.severity === 'error')
},
// Do not show any tooltips for highlights within the editor content
tooltipFilter() {
return []
},
needsRefresh(update: ViewUpdate) {
return update.selectionSet
},
}
/**
* A lint source using the compile log diagnostics
*/
const compileLogLintSource = (): Extension =>
linter(view => {
const items: CompileLogDiagnostic[] = []
// NOTE: iter() changes the order of diagnostics on the same line
const cursor = view.state.field(compileDiagnosticsState).iter()
while (cursor.value !== null) {
const { diagnostic } = cursor.value
items.push({
...diagnostic,
from: cursor.from,
to: cursor.to,
renderMessage: () => renderMessage(diagnostic),
})
cursor.next()
}
// restore the original order of items
items.sort((a, b) => a.from - b.from || a.entryIndex - b.entryIndex)
return items
}, lintSourceConfig)
class CompileLogDiagnosticRangeValue extends RangeValue {
constructor(public diagnostic: CompileLogDiagnostic) {
super()
}
}
const setCompileDiagnosticsEffect = StateEffect.define<CompileLogDiagnostic[]>()
/**
* A state field for the compile log diagnostics
*/
export const compileDiagnosticsState = StateField.define<
RangeSet<CompileLogDiagnosticRangeValue>
>({
create() {
return RangeSet.empty
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setCompileDiagnosticsEffect)) {
return RangeSet.of(
effect.value.map(diagnostic =>
new CompileLogDiagnosticRangeValue(diagnostic).range(
diagnostic.from,
diagnostic.to
)
),
true
)
}
}
if (transaction.docChanged) {
value = value.map(transaction.changes)
}
return value
},
})
export const setAnnotations = (
state: EditorState,
annotations: Annotation[]
) => {
const diagnostics: CompileLogDiagnostic[] = []
for (const annotation of annotations) {
// ignore "whole document" (row: -1) annotations
if (annotation.row !== -1) {
try {
diagnostics.push(...convertAnnotationToDiagnostic(state, annotation))
} catch (error) {
// ignore invalid annotations
debugConsole.debug('invalid annotation position', error)
}
}
}
return {
effects: setCompileDiagnosticsEffect.of(diagnostics),
}
}
export const showCompileLogDiagnostics = (show: boolean) => {
return {
effects: [
// reconfigure the compile log lint source
compileLintSourceConf.reconfigure(show ? compileLogLintSource() : []),
],
}
}
const commandRanges = (state: EditorState, line: Line, command: string) => {
const ranges: { from: number; to: number }[] = []
syntaxTree(state).iterate({
enter(nodeRef) {
if (nodeRef.type.is('CtrlSeq')) {
const { from, to } = nodeRef
if (command === state.sliceDoc(from, to)) {
ranges.push({ from, to })
}
}
},
from: line.from,
to: line.to,
})
return ranges.slice(0, 1) // NOTE: only highlighting the first match on a line, to avoid duplicate messages
}
const chooseHighlightRanges = (
state: EditorState,
line: Line,
annotation: Annotation
) => {
const ranges: { from: number; to: number }[] = []
if (annotation.command) {
ranges.push(...commandRanges(state, line, annotation.command))
}
// default to highlighting the whole line
if (ranges.length === 0) {
ranges.push(line)
}
return ranges
}
const convertAnnotationToDiagnostic = (
state: EditorState,
annotation: Annotation
): CompileLogDiagnostic[] => {
if (annotation.row < 0) {
throw new Error(`Invalid annotation row ${annotation.row}`)
}
// NOTE: highlight whole line by default, as synctex doesn't output column number
const line = state.doc.line(annotation.row + 1)
const highlightRanges = chooseHighlightRanges(state, line, annotation)
return highlightRanges.map(location => ({
from: location.from,
to: location.to,
severity: annotation.type,
message: annotation.text,
ruleId: annotation.ruleId,
compile: true,
id: annotation.id,
entryIndex: annotation.entryIndex,
source: annotation.source,
firstOnLine: annotation.firstOnLine,
}))
}
export const renderMessage = (diagnostic: RenderedDiagnostic) => {
const { message, severity, ruleId, compile = false } = diagnostic
const div = document.createElement('div')
div.classList.add('ol-cm-diagnostic-message')
div.append(message)
const activeDiagnosticActions = diagnosticActions
.map(m => m.import.default(diagnostic))
.filter(Boolean) as HTMLButtonElement[]
if (activeDiagnosticActions.length) {
const actions = document.createElement('div')
actions.classList.add('ol-cm-diagnostic-actions')
actions.append(...activeDiagnosticActions)
div.append(actions)
}
window.setTimeout(() => {
if (div.isConnected) {
sendMB('lint-gutter-marker-view', { severity, ruleId, compile })
}
}, 500) // 500ms delay to indicate intention, rather than accidental hover
return div
}

View File

@@ -0,0 +1,197 @@
import {
acceptCompletion,
autocompletion,
closeCompletion,
moveCompletionSelection,
startCompletion,
Completion,
} from '@codemirror/autocomplete'
import { EditorView, keymap } from '@codemirror/view'
import {
Compartment,
Extension,
Prec,
TransactionSpec,
} from '@codemirror/state'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
const moduleExtensions: Array<(options: Record<string, any>) => Extension> =
importOverleafModules('autoCompleteExtensions').map(
(item: { import: { extension: Extension } }) => item.import.extension
)
const autoCompleteConf = new Compartment()
type AutoCompleteOptions = {
enabled: boolean
} & Record<string, any>
export const autoComplete = ({ enabled, ...rest }: AutoCompleteOptions) =>
autoCompleteConf.of(createAutoComplete({ enabled, ...rest }))
export const setAutoComplete = ({
enabled,
...rest
}: AutoCompleteOptions): TransactionSpec => {
return {
effects: autoCompleteConf.reconfigure(
createAutoComplete({ enabled, ...rest })
),
}
}
const createAutoComplete = ({ enabled, ...rest }: AutoCompleteOptions) => {
if (!enabled) {
return []
}
return [
[
autocompleteTheme,
/**
* A built-in extension which provides the autocomplete feature,
* configured with a custom render function and
* a zero interaction delay (so that keypresses are handled after the autocomplete is opened).
*/
autocompletion({
icons: false,
defaultKeymap: false,
addToOptions: [
// display the completion "type" at the end of the suggestion
{
render: completion => {
const span = document.createElement('span')
span.classList.add('ol-cm-completionType')
if (completion.type) {
span.textContent = completion.type
}
return span
},
position: 400,
},
],
optionClass: (completion: Completion) => {
return `ol-cm-completion-${completion.type}`
},
interactionDelay: 0,
}),
/**
* A keymap which adds Tab for accepting a completion and Ctrl-Space for opening autocomplete.
*/
Prec.highest(
keymap.of([
{ key: 'Escape', run: closeCompletion },
{ key: 'ArrowDown', run: moveCompletionSelection(true) },
{ key: 'ArrowUp', run: moveCompletionSelection(false) },
{ key: 'PageDown', run: moveCompletionSelection(true, 'page') },
{ key: 'PageUp', run: moveCompletionSelection(false, 'page') },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Tab', run: acceptCompletion },
])
),
/**
* A keymap which positions Ctrl-Space and Alt-Space below the corresponding bindings for advanced reference search.
*/
Prec.high(
keymap.of([
{ key: 'Ctrl-Space', run: startCompletion },
{ key: 'Alt-Space', run: startCompletion },
])
),
],
moduleExtensions.map(extension => extension({ ...rest })),
]
}
const AUTOCOMPLETE_LINE_HEIGHT = 1.4
/**
* Styles for the autocomplete menu
*/
const autocompleteTheme = EditorView.baseTheme({
'.cm-tooltip.cm-tooltip-autocomplete': {
// shift the tooltip, so the completion aligns with the text
marginLeft: '-4px',
},
'&light .cm-tooltip.cm-tooltip-autocomplete, &light .cm-tooltip.cm-completionInfo':
{
border: '1px lightgray solid',
background: '#fefefe',
color: '#111',
boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)',
},
'&dark .cm-tooltip.cm-tooltip-autocomplete, &dark .cm-tooltip.cm-completionInfo':
{
border: '1px #484747 solid',
boxShadow: '2px 3px 5px rgba(0, 0, 0, 0.51)',
background: '#25282c',
color: '#c1c1c1',
},
// match editor font family and font size, so the completion aligns with the text
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
fontFamily: 'var(--source-font-family)',
fontSize: 'var(--font-size)',
},
'.cm-tooltip.cm-tooltip-autocomplete li[role="option"]': {
display: 'flex',
justifyContent: 'space-between',
lineHeight: AUTOCOMPLETE_LINE_HEIGHT, // increase the line height from default 1.2, for a larger target area
outline: '1px solid transparent',
},
'.cm-tooltip .cm-completionDetail': {
flex: '1 0 auto',
fontSize: 'calc(var(--font-size) * 1.4)',
lineHeight: `calc(var(--font-size) * ${AUTOCOMPLETE_LINE_HEIGHT})`,
overflow: 'hidden',
// By default CodeMirror styles the details as italic
fontStyle: 'normal !important',
// We use this element for the symbol palette, so change the font to the
// symbol palette font
fontFamily: "'Stix Two Math', serif",
},
'&light .cm-tooltip.cm-tooltip-autocomplete li[role="option"]:hover': {
outlineColor: '#abbffe',
backgroundColor: 'rgba(233, 233, 253, 0.4)',
},
'&dark .cm-tooltip.cm-tooltip-autocomplete li[role="option"]:hover': {
outlineColor: 'rgba(109, 150, 13, 0.8)',
backgroundColor: 'rgba(58, 103, 78, 0.62)',
},
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
color: 'inherit',
},
'&light .cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
background: '#cad6fa',
},
'&dark .cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
background: '#3a674e',
},
'.cm-completionMatchedText': {
textDecoration: 'none', // remove default underline,
},
'&light .cm-completionMatchedText': {
color: '#2d69c7',
},
'&dark .cm-completionMatchedText': {
color: '#93ca12',
},
'.ol-cm-completionType': {
paddingLeft: '1em',
paddingRight: 0,
width: 'auto',
fontSize: '90%',
fontFamily: 'var(--source-font-family)',
opacity: '0.5',
},
'.cm-completionInfo .ol-cm-symbolCompletionInfo': {
margin: 0,
whiteSpace: 'normal',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
},
'.cm-completionInfo .ol-cm-symbolCharacter': {
fontSize: '32px',
},
})

View File

@@ -0,0 +1,26 @@
import { keymap } from '@codemirror/view'
import { Compartment, Prec, TransactionSpec } from '@codemirror/state'
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
const autoPairConf = new Compartment()
export const autoPair = ({
autoPairDelimiters,
}: {
autoPairDelimiters: boolean
}) => autoPairConf.of(autoPairDelimiters ? extension : [])
export const setAutoPair = (autoPairDelimiters: boolean): TransactionSpec => {
return {
effects: autoPairConf.reconfigure(autoPairDelimiters ? extension : []),
}
}
/**
* The built-in closeBrackets extension and closeBrackets keymap.
*/
const extension = [
closeBrackets(),
// NOTE: using Prec.highest as this needs to run before the default Backspace handler
Prec.highest(keymap.of(closeBracketsKeymap)),
]

View File

@@ -0,0 +1,168 @@
import {
bracketMatching as bracketMatchingExtension,
matchBrackets,
type MatchResult,
} from '@codemirror/language'
import { Decoration, EditorView } from '@codemirror/view'
import {
EditorSelection,
Extension,
SelectionRange,
type Range,
} from '@codemirror/state'
import browser from '@/features/source-editor/extensions/browser'
const matchingMark = Decoration.mark({ class: 'cm-matchingBracket' })
const nonmatchingMark = Decoration.mark({ class: 'cm-nonmatchingBracket' })
const FORWARDS = 1
const BACKWARDS = -1
type Direction = 1 | -1
/**
* A built-in extension which decorates matching pairs of brackets when focused,
* configured with a custom render function that combines adjacent pairs of matching markers
* into a single decoration so theres no border between them.
*/
export const bracketMatching = () => {
return bracketMatchingExtension({
renderMatch: match => {
const decorations: Range<Decoration>[] = []
if (matchedAdjacent(match)) {
// combine an adjacent pair of matching markers into a single decoration
decorations.push(
matchingMark.range(
Math.min(match.start.from, match.end.from),
Math.max(match.start.to, match.end.to)
)
)
} else {
// default match rendering (defaultRenderMatch in @codemirror/matchbrackets)
const mark = match.matched ? matchingMark : nonmatchingMark
decorations.push(mark.range(match.start.from, match.start.to))
if (match.end) {
decorations.push(mark.range(match.end.from, match.end.to))
}
}
return decorations
},
})
}
interface AdjacentMatchResult extends MatchResult {
end: {
from: number
to: number
}
}
const matchedAdjacent = (match: MatchResult): match is AdjacentMatchResult =>
Boolean(
match.matched &&
match.end &&
(match.start.to === match.end.from || match.end.to === match.start.from)
)
/**
* A custom extension which handles double-click events on a matched bracket
* and extends the selection to cover the contents of the bracket pair.
*/
export const bracketSelection = (): Extension => {
return [chooseEventHandler(), matchingBracketTheme]
}
const chooseEventHandler = () => {
// Safari doesn't always fire the second "click" event, so use dblclick instead
if (browser.safari) {
return [
EditorView.domEventHandlers({
dblclick: handleDoubleClick,
}),
]
}
// Store and use the previous click event, as the "dblclick" event can have the wrong position
let lastClickEvent: MouseEvent | null = null
return EditorView.domEventHandlers({
click(evt, view) {
if (evt.detail === 1) {
lastClickEvent = evt
} else if (evt.detail === 2 && lastClickEvent) {
return handleDoubleClick(lastClickEvent, view)
}
},
})
}
const handleDoubleClick = (evt: MouseEvent, view: EditorView) => {
const pos = view.posAtCoords({
x: evt.pageX,
y: evt.pageY,
})
if (!pos) return false
const search = (direction: Direction, position: number) => {
const match = matchBrackets(view.state, position, direction, {
// Only look at data in the syntax tree, don't scan the text
maxScanDistance: 0,
})
if (match?.matched && match.end) {
return EditorSelection.range(
Math.min(match.start.from, match.end.from),
Math.max(match.end.to, match.start.to)
)
}
return false
}
const dispatchSelection = (range: SelectionRange) => {
view.dispatch({
selection: range,
})
return true
}
// 1. Look forwards, from the character *behind* the cursor
const forwardsExcludingBrackets = search(FORWARDS, pos - 1)
if (forwardsExcludingBrackets) {
return dispatchSelection(
EditorSelection.range(
forwardsExcludingBrackets.from + 1,
forwardsExcludingBrackets.to - 1
)
)
}
// 2. Look forwards, from the character *in front of* the cursor
const forwardsIncludingBrackets = search(FORWARDS, pos)
if (forwardsIncludingBrackets) {
return dispatchSelection(forwardsIncludingBrackets)
}
// 3. Look backwards, from the character *behind* the cursor
const backwardsIncludingBrackets = search(BACKWARDS, pos)
if (backwardsIncludingBrackets) {
return dispatchSelection(backwardsIncludingBrackets)
}
// 4. Look backwards, from the character *in front of* the cursor
const backwardsExcludingBrackets = search(BACKWARDS, pos + 1)
if (backwardsExcludingBrackets) {
return dispatchSelection(
EditorSelection.range(
backwardsExcludingBrackets.from + 1,
backwardsExcludingBrackets.to - 1
)
)
}
return false
}
const matchingBracketTheme = EditorView.baseTheme({
'.cm-matchingBracket': {
pointerEvents: 'none',
},
})

View File

@@ -0,0 +1,41 @@
import { EditorView } from '@codemirror/view'
/**
* A panel which contains the editor breadcrumbs
*/
export function breadcrumbPanel() {
return [
EditorView.editorAttributes.of({
style: '--breadcrumbs-height: 28px;',
}),
EditorView.baseTheme({
'.ol-cm-breadcrumbs-portal': {
display: 'flex',
pointerEvents: 'none !important',
'& > *': {
pointerEvents: 'all',
},
},
'.ol-cm-breadcrumbs': {
height: 'var(--breadcrumbs-height)',
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-01)',
fontSize: 'var(--font-size-01)',
padding: 'var(--spacing-02)',
overflow: 'auto',
scrollbarWidth: 'thin',
'& > *': {
flexShrink: '0',
},
},
'&light .ol-cm-breadcrumb-chevron': {
color: 'var(--neutral-30)',
},
'&dark .ol-cm-breadcrumb-chevron': {
color: 'var(--neutral-50)',
},
}),
]
}

View File

@@ -0,0 +1,50 @@
// This is copied from CM6, which does not expose it publicly.
// https://github.com/codemirror/view/blob/e7918b607753588a0b2a596e952068fa008bf84c/src/browser.ts
const nav: any =
typeof navigator !== 'undefined'
? navigator
: { userAgent: '', vendor: '', platform: '' }
const doc: any =
typeof document !== 'undefined'
? document
: { documentElement: { style: {} } }
const ieEdge = /Edge\/(\d+)/.exec(nav.userAgent)
const ieUpTo10 = /MSIE \d/.test(nav.userAgent)
const ie11Up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(nav.userAgent)
const ie = !!(ieUpTo10 || ie11Up || ieEdge)
const gecko = !ie && /gecko\/(\d+)/i.test(nav.userAgent)
const chrome = !ie && /Chrome\/(\d+)/.exec(nav.userAgent)
const webkit = 'webkitFontSmoothing' in doc.documentElement.style
const safari = !ie && /Apple Computer/.test(nav.vendor)
const ios =
safari && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2)
export default {
mac: ios || /Mac/.test(nav.platform),
windows: /Win/.test(nav.platform),
linux: /Linux|X11/.test(nav.platform),
ie,
ie_version: ieUpTo10
? doc.documentMode || 6
: ie11Up
? +ie11Up[1]
: ieEdge
? +ieEdge[1]
: 0,
gecko,
gecko_version: gecko
? +(/Firefox\/(\d+)/.exec(nav.userAgent) || [0, 0])[1]
: 0,
chrome: !!chrome,
chrome_version: chrome ? +chrome[1] : 0,
ios,
android: /Android\b/.test(nav.userAgent),
webkit,
safari,
webkit_version: webkit
? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1]
: 0,
tabSize:
doc.documentElement.style.tabSize != null ? 'tab-size' : '-moz-tab-size',
}

View File

@@ -0,0 +1,36 @@
import { syntaxTree } from '@codemirror/language'
import {
EditorSelection,
Prec,
StateEffect,
StateField,
} from '@codemirror/state'
import {
Decoration,
EditorView,
hoverTooltip,
keymap,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import { CodeMirror, Vim, getCM } from '@replit/codemirror-vim'
export default {
Decoration,
EditorSelection,
EditorView,
Prec,
StateEffect,
StateField,
ViewPlugin,
WidgetType,
hoverTooltip,
keymap,
syntaxTree,
}
export const CodeMirrorVim = {
CodeMirror,
Vim,
getCM,
}

View File

@@ -0,0 +1,226 @@
import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state'
import {
AnyOperation,
Change,
CommentOperation,
} from '../../../../../../types/change'
import { ThreadId } from '../../../../../../types/review-panel/review-panel'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
export type StoredComment = {
text: string
comments: {
offset: number
text: string
comment: Change<CommentOperation>
}[]
}
/**
* Find tracked comments within the range of the current transaction's changes
*/
export const findCommentsInCut = (
currentDoc: DocumentContainer,
transaction: Transaction
) => {
const items: StoredComment[] = []
transaction.changes.iterChanges((fromA, toA) => {
const comments = currentDoc
.ranges!.comments.filter(
comment =>
fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA
)
.map(comment => ({
offset: comment.op.p - fromA,
text: comment.op.c,
comment,
}))
if (comments.length) {
items.push({
text: transaction.startState.sliceDoc(fromA, toA),
comments,
})
}
})
return items
}
/**
* Find stored comments matching the text of the current transaction's changes
*/
export const findCommentsInPaste = (
storedComments: StoredComment[],
transaction: Transaction
) => {
const ops: CommentOperation[] = []
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const insertedText = inserted.toString()
// note: only using the first match
const matchedComment = storedComments.find(
item => item.text === insertedText
)
if (matchedComment) {
for (const { offset, text, comment } of matchedComment.comments) {
// Resubmitting an existing comment op (by thread id) will move it
ops.push({
c: text,
p: fromB + offset,
t: comment.id as ThreadId,
})
}
}
})
return ops
}
class CommentRangeValue extends RangeValue {
constructor(
public content: string,
public comment: Change<CommentOperation>
) {
super()
}
}
/**
* Find tracked comments with no content with the ranges of a transaction's changes
*/
export const findDetachedCommentsInChanges = (
currentDoc: DocumentContainer,
transaction: Transaction
) => {
const items: Range<CommentRangeValue>[] = []
transaction.changes.iterChanges((fromA, toA) => {
for (const comment of currentDoc.ranges!.comments) {
const content = comment.op.c
// TODO: handle comments that were never attached
if (!content.length) {
continue
}
const from = comment.op.p
const to = from + content.length
if (fromA <= from && to <= toA) {
items.push(new CommentRangeValue(content, comment).range(from, to))
}
}
})
return RangeSet.of(items, true)
}
/**
* Submit operations to the ShareJS doc
* (used when restoring comments on paste)
*/
const submitOps = (
currentDoc: DocumentContainer,
ops: AnyOperation[],
transaction: Transaction
) => {
for (const op of ops) {
currentDoc.submitOp(op)
}
// Check that comments still match text. Will throw error if not.
currentDoc.ranges!.validate(transaction.state.doc.toString())
}
/**
* Wait for the ShareJS doc to fire an event, then submit the operations.
*/
const submitOpsAfterEvent = (
currentDoc: DocumentContainer,
eventName: string,
ops: AnyOperation[],
transaction: Transaction
) => {
// We have to wait until the change has been processed by the range
// tracker, since if we move the ops into place beforehand, they will be
// moved again when the changes are processed by the range tracker. This
// ranges:dirty event is fired after the doc has applied the changes to
// the range tracker.
// TODO: could put this in an update listener instead, if the ShareJS doc has been updated by then?
currentDoc.on(eventName, () => {
currentDoc.off(eventName)
window.setTimeout(() => submitOps(currentDoc, ops, transaction))
})
}
/**
* Look through the comments stored on cut, and restore those in text that matches the pasted text.
*/
export const restoreCommentsOnPaste = (
currentDoc: DocumentContainer,
transaction: Transaction,
storedComments: StoredComment[]
) => {
if (storedComments.length) {
const ops = findCommentsInPaste(storedComments, transaction)
if (ops.length) {
submitOpsAfterEvent(
currentDoc,
'ranges:dirty.paste-cm6',
ops,
transaction
)
}
}
}
/**
* When undoing a change, find comments from the original content and restore them.
*/
export const restoreDetachedComments = (
currentDoc: DocumentContainer,
transaction: Transaction,
storedComments: RangeSet<any>
) => {
const ops: CommentOperation[] = []
const cursor = storedComments.iter()
while (cursor.value) {
const { id } = cursor.value.comment
const comment = currentDoc.ranges!.comments.find(item => item.id === id)
// check that the comment still exists and is detached
if (comment && comment.op.c === '') {
const content = transaction.state.doc.sliceString(
cursor.from,
cursor.from + cursor.value.content.length
)
if (cursor.value.content === content) {
ops.push({
c: cursor.value.content,
p: cursor.from,
t: id,
})
}
}
cursor.next()
}
// FIXME: timing issue with rapid undos
if (ops.length) {
window.setTimeout(() => {
submitOps(currentDoc, ops, transaction)
}, 0)
}
// submitOpsAfterEvent('ranges:dirty.undo-cm6', ops, transaction)
}

View File

@@ -0,0 +1,106 @@
import { EditorState } from '@codemirror/state'
import { Change, EditOperation } from '../../../../../../types/change'
import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { trackChangesAnnotation } from '@/features/source-editor/extensions/realtime'
/**
* Remove tracked changes from the range tracker when they're rejected,
* and restore the original content
*/
export const rejectChanges = (
state: EditorState,
ranges: DocumentContainer['ranges'],
changeIds: string[]
) => {
const changes = ranges!.getChanges(changeIds) as Change<EditOperation>[]
if (changes.length === 0) {
return {}
}
// When doing bulk rejections, adjacent changes might interact with each other.
// Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
//
// "foo bar baz" -> "foo quux baz"
//
// The change above will be modeled with two ops, with the insertion going first:
//
// foo quux baz
// |--| -> insertion of "quux", op 1, at position 4
// | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
//
// When rejecting these changes at once, if the insertion is rejected first, we get unexpected
// results. What happens is:
//
// 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
// starting from position 4;
//
// "foo quux baz" -> "foo baz"
// |--| -> 4 characters to be removed
//
// 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
// the word "quuux" was still present).
//
// "foo baz" -> "foo bazbar"
// | -> deletion of "bar" is reverted by reinserting "bar" at position 8
//
// While the intended result would be "foo bar baz", what we get is:
//
// "foo bazbar" (note "bar" readded at position 8)
//
// The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
// from position 4. This includes the position where the deletion exists; when that position is
// cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
// As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
// does so at the outdated position (position 8, which was valid when "quux" was present).
//
// To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
// subsequent operations that come after. Reverse sorting the operations based on position will
// achieve it; in the case above, it makes sure that the the deletion is reverted first:
//
// 1) Rejecting the deletion adds the deleted word "bar" at position 8
//
// "foo quux baz" -> "foo quuxbar baz"
// | -> deletion of "bar" is reverted by
// reinserting "bar" at position 8
//
// 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
// starting from position 4 and achieves the expected result:
//
// "foo quuxbar baz" -> "foo bar baz"
// |--| -> 4 characters to be removed
changes.sort((a, b) => b.op.p - a.op.p)
const changesToDispatch = changes.map(change => {
const { op } = change
if (isInsertOperation(op)) {
const from = op.p
const content = op.i
const to = from + content.length
const text = state.doc.sliceString(from, to)
if (text !== content) {
throw new Error(`Op to be removed does not match editor text`)
}
return { from, to, insert: '' }
} else if (isDeleteOperation(op)) {
return {
from: op.p,
to: op.p,
insert: op.d,
}
} else {
throw new Error(`unknown change type: ${JSON.stringify(change)}`)
}
})
return {
changes: changesToDispatch,
annotations: [trackChangesAnnotation.of('reject')],
}
}

View File

@@ -0,0 +1,49 @@
/**
* This file is adapted from Lezer, licensed under the MIT license:
* https://github.com/lezer-parser/highlight/blob/main/src/highlight.ts
*/
import { tagHighlighter, tags } from '@lezer/highlight'
export const classHighlighter = tagHighlighter([
{ tag: tags.link, class: 'tok-link' },
{ tag: tags.heading, class: 'tok-heading' },
{ tag: tags.emphasis, class: 'tok-emphasis' },
{ tag: tags.strong, class: 'tok-strong' },
{ tag: tags.keyword, class: 'tok-keyword' },
{ tag: tags.atom, class: 'tok-atom' },
{ tag: tags.bool, class: 'tok-bool' },
{ tag: tags.url, class: 'tok-url' },
{ tag: tags.labelName, class: 'tok-labelName' },
{ tag: tags.inserted, class: 'tok-inserted' },
{ tag: tags.deleted, class: 'tok-deleted' },
{ tag: tags.literal, class: 'tok-literal' },
{ tag: tags.string, class: 'tok-string' },
{ tag: tags.number, class: 'tok-number' },
{
tag: [tags.regexp, tags.escape, tags.special(tags.string)],
class: 'tok-string2',
},
{ tag: tags.variableName, class: 'tok-variableName' },
{ tag: tags.local(tags.variableName), class: 'tok-variableName tok-local' },
{
tag: tags.definition(tags.variableName),
class: 'tok-variableName tok-definition',
},
{ tag: tags.special(tags.variableName), class: 'tok-variableName2' },
{
tag: tags.definition(tags.propertyName),
class: 'tok-propertyName tok-definition',
},
{ tag: tags.typeName, class: 'tok-typeName' },
{ tag: tags.namespace, class: 'tok-namespace' },
{ tag: tags.className, class: 'tok-className' },
{ tag: tags.macroName, class: 'tok-macroName' },
{ tag: tags.propertyName, class: 'tok-propertyName' },
{ tag: tags.operator, class: 'tok-operator' },
{ tag: tags.comment, class: 'tok-comment' },
{ tag: tags.meta, class: 'tok-meta' },
{ tag: tags.invalid, class: 'tok-invalid' },
{ tag: tags.punctuation, class: 'tok-punctuation' },
// additional
{ tag: tags.attributeValue, class: 'tok-attributeValue' },
])

View File

@@ -0,0 +1,363 @@
import {
Decoration,
EditorView,
getTooltip,
keymap,
showTooltip,
Tooltip,
TooltipView,
ViewUpdate,
} from '@codemirror/view'
import {
EditorSelection,
EditorState,
Prec,
SelectionRange,
StateEffect,
StateField,
} from '@codemirror/state'
import { ancestorOfNodeWithType } from '../utils/tree-query'
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import {
FilePathArgument,
LiteralArgContent,
RefArgument,
ShortArg,
ShortTextArgument,
UrlArgument,
} from '../lezer-latex/latex.terms.mjs'
import {
hasNextSnippetField,
selectedCompletion,
} from '@codemirror/autocomplete'
import { SyntaxNode } from '@lezer/common'
type ActiveTooltip = {
range: SelectionRange
tooltip: Tooltip
command: string
} | null
const createTooltipView = (
update?: (update: ViewUpdate) => void
): TooltipView => {
const dom = document.createElement('div')
dom.role = 'menu'
dom.classList.add('ol-cm-command-tooltip')
return { dom, update }
}
const buildTooltip = (
command: string,
pos: number,
value: ActiveTooltip,
commandNode: SyntaxNode,
argumentNode?: SyntaxNode | null,
update?: (update: ViewUpdate) => void
): ActiveTooltip => {
if (!argumentNode) {
return null
}
const { from, to } = commandNode
// if the node still matches the range (i.e. this is the same node),
// re-use the tooltip by supplying the same create function
if (value && from === value.range.from && to === value.range.to) {
return {
...value,
tooltip: { ...value.tooltip, pos },
}
}
return {
command,
range: EditorSelection.range(from, to),
tooltip: {
create: () => createTooltipView(update), // ensure a new create function
arrow: true,
pos,
},
}
}
const createTooltipState = (
state: EditorState,
value: ActiveTooltip
): ActiveTooltip => {
// NOTE: only handling the main selection
const { main } = state.selection
const pos = main.head
const node = syntaxTree(state).resolveInner(pos, 0)
const commandNode = ancestorOfNodeWithType(node, '$CommandTooltipCommand')
if (!commandNode) {
return null
}
// only show the tooltip when the selection is completely inside the node
if (main.from < commandNode.from || main.to > commandNode.to) {
return null
}
const commandName = commandNode.name
switch (commandName) {
// a hyperlink (\href)
case 'HrefCommand': {
const argumentNode = commandNode
.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (
argumentNode &&
state.sliceDoc(argumentNode.from, argumentNode.to).includes('\n')
) {
return null
}
const update = (update: ViewUpdate) => {
const tooltipState = commandTooltipState(update.state)
const input =
tooltipState && firstInteractiveElement(update.view, tooltipState)
if (input && document.activeElement !== input) {
const commandNode = resolveCommandNode(update.state)
const argumentNode = commandNode
?.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (argumentNode) {
const url = update.state.sliceDoc(
argumentNode.from,
argumentNode.to
)
if (url !== input.value) {
input.dispatchEvent(
new CustomEvent('value-update', {
detail: url,
})
)
}
}
}
}
return buildTooltip(
commandName,
pos,
value,
commandNode,
argumentNode,
update
)
}
// a URL (\url)
case 'UrlCommand': {
const argumentNode = commandNode
.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (argumentNode) {
const content = state
.sliceDoc(argumentNode.from, argumentNode.to)
.trim()
if (
!content ||
content.includes('\n') ||
!/^https?:\/\/\w+/.test(content)
) {
return null
}
}
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
// a cross-reference (\ref)
case 'Ref': {
const argumentNode = commandNode
.getChild(RefArgument)
?.getChild(ShortTextArgument)
?.getChild(ShortArg)
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
// an included file (\include)
case 'Include': {
const argumentNode = commandNode
.getChild('IncludeArgument')
?.getChild(FilePathArgument)
?.getChild(LiteralArgContent)
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
// an input file (\input)
case 'Input': {
const argumentNode = commandNode
.getChild('InputArgument')
?.getChild(FilePathArgument)
?.getChild(LiteralArgContent)
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
}
return null
}
const commandTooltipTheme = EditorView.baseTheme({
'&light .cm-tooltip.ol-cm-command-tooltip': {
border: '1px lightgray solid',
background: '#fefefe',
color: '#111',
boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)',
},
'&dark .cm-tooltip.ol-cm-command-tooltip': {
border: '1px #484747 solid',
background: '#25282c',
color: '#c1c1c1',
boxShadow: '2px 3px 5px rgba(0, 0, 0, 0.51)',
},
'.ol-cm-command-tooltip-content': {
padding: '8px 0',
display: 'flex',
flexDirection: 'column',
},
'.btn-link.ol-cm-command-tooltip-link': {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 12px',
textDecoration: 'none',
color: 'inherit',
},
'.ol-cm-command-tooltip-form': {
padding: '0 8px',
},
})
export const resolveCommandNode = (state: EditorState) => {
const tooltipState = commandTooltipState(state)
if (tooltipState) {
const pos = tooltipState.range.from
const tree = ensureSyntaxTree(state, pos)
if (tree) {
return tree.resolveInner(pos, 1).parent
}
}
}
const closeCommandTooltipEffect = StateEffect.define()
export const closeCommandTooltip = () => {
return {
effects: closeCommandTooltipEffect.of(null),
}
}
export const commandTooltipStateField = StateField.define<ActiveTooltip>({
create(state) {
return createTooltipState(state, null)
},
update(value, tr) {
if (tr.effects.some(effect => effect.is(closeCommandTooltipEffect))) {
// close the tooltip if this effect is present
value = null
} else if (selectedCompletion(tr.state)) {
// don't show tooltip if autocomplete is open
value = null
} else {
if (value) {
// map the stored range through changes
value.range = value.range.map(tr.changes)
}
if (tr.docChanged || tr.selection) {
// create/update the tooltip
value = createTooltipState(tr.state, value)
}
}
return value
},
provide(field) {
return [
// show the tooltip when defined
showTooltip.from(field, field => (field ? field.tooltip : null)),
// set attributes on the node with the popover
EditorView.decorations.from(field, field => {
if (!field) {
return Decoration.none
}
return Decoration.set(
Decoration.mark({
attributes: {
'aria-haspopup': 'menu',
},
}).range(field.range.from, field.range.to)
)
}),
]
},
})
export const commandTooltipState = (
state: EditorState
): ActiveTooltip | undefined => state.field(commandTooltipStateField, false)
const firstInteractiveElement = (
view: EditorView,
tooltipState: NonNullable<ActiveTooltip>
) =>
getTooltip(view, tooltipState.tooltip)?.dom.querySelector<
HTMLInputElement | HTMLButtonElement
>('input, button')
const commandTooltipKeymap = Prec.highest(
keymap.of([
{
key: 'Tab',
// Tab to focus the first element in the tooltip, if open
run(view) {
const tooltipState = commandTooltipState(view.state)
if (tooltipState) {
const element = firstInteractiveElement(view, tooltipState)
if (element) {
// continue to the next snippet if there's already a URL filled in
if (
element.type === 'url' &&
element.value &&
hasNextSnippetField(view.state)
) {
return false
}
element.focus()
return true
}
}
return false
},
},
{
key: 'Escape',
// Escape to close the tooltip, if open
run(view) {
const tooltipState = commandTooltipState(view.state)
if (tooltipState) {
view.dispatch(closeCommandTooltip())
}
return false
},
},
])
)
export const commandTooltip = [
commandTooltipStateField,
commandTooltipKeymap,
commandTooltipTheme,
]

View File

@@ -0,0 +1,221 @@
import {
MapMode,
RangeSet,
RangeValue,
StateEffect,
StateField,
Transaction,
TransactionSpec,
} from '@codemirror/state'
import {
EditorView,
hoverTooltip,
layer,
RectangleMarker,
Tooltip,
} from '@codemirror/view'
import { findValidPosition } from '../utils/position'
import { Highlight } from '../../../../../types/highlight'
import { fullHeightCoordsAtPos, getBase } from '../utils/layer'
import { debugConsole } from '@/utils/debugging'
/**
* A custom extension that displays collaborator cursors in a separate layer.
*/
export const cursorHighlights = () => {
return [
cursorHighlightsState,
cursorHighlightsLayer,
cursorHighlightsTheme,
hoverTooltip(cursorTooltip, {
hoverTime: 1,
}),
]
}
const cursorHighlightsTheme = EditorView.theme({
'.ol-cm-cursorHighlightsLayer': {
zIndex: 100,
contain: 'size style',
pointerEvents: 'none',
},
'.ol-cm-cursorHighlight': {
color: 'hsl(var(--hue), 70%, 50%)',
borderLeft: '2px solid hsl(var(--hue), 70%, 50%)',
display: 'inline-block',
height: '1.6em',
position: 'absolute',
pointerEvents: 'none',
},
'.ol-cm-cursorHighlight:before': {
content: "''",
position: 'absolute',
left: '-2px',
top: '-5px',
height: '5px',
width: '5px',
borderWidth: '3px 3px 2px 2px',
borderStyle: 'solid',
borderColor: 'inherit',
},
'.ol-cm-cursorHighlightLabel': {
lineHeight: 1,
backgroundColor: 'hsl(var(--hue), 70%, 50%)',
padding: '1em 1em',
fontSize: '0.8rem',
fontFamily: 'Lato, sans-serif',
color: 'white',
fontWeight: 700,
whiteSpace: 'nowrap',
pointerEvents: 'none',
},
})
class HighlightRangeValue extends RangeValue {
mapMode = MapMode.Simple
constructor(public highlight: Highlight) {
super()
}
eq(other: HighlightRangeValue) {
return other.highlight === this.highlight
}
}
const cursorHighlightsState = StateField.define<RangeSet<HighlightRangeValue>>({
create() {
return RangeSet.empty
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setCursorHighlightsEffect)) {
const highlightRanges = []
for (const highlight of effect.value) {
// NOTE: other highlight types could be handled here
if ('cursor' in highlight) {
try {
const { row, column } = highlight.cursor
const pos = findValidPosition(tr.state.doc, row + 1, column)
highlightRanges.push(
new HighlightRangeValue(highlight).range(pos)
)
} catch (error) {
// ignore invalid highlights
debugConsole.debug('invalid highlight position', error)
}
}
}
return RangeSet.of(highlightRanges, true)
}
}
if (tr.docChanged && !tr.annotation(Transaction.remote)) {
value = value.map(tr.changes)
}
return value
},
})
const cursorTooltip = (view: EditorView, pos: number): Tooltip | null => {
const highlights: Highlight[] = []
view.state
.field(cursorHighlightsState)
.between(pos, pos, (from, to, value) => {
highlights.push(value.highlight)
})
if (highlights.length === 0) {
return null
}
return {
pos,
end: pos,
above: true,
create: () => {
const dom = document.createElement('div')
dom.classList.add('ol-cm-cursorTooltip')
for (const highlight of highlights) {
const label = document.createElement('div')
label.classList.add('ol-cm-cursorHighlightLabel')
label.style.setProperty('--hue', String(highlight.hue))
label.textContent = highlight.label
dom.appendChild(label)
}
return { dom }
},
}
}
const setCursorHighlightsEffect = StateEffect.define<Highlight[]>()
export const setCursorHighlights = (
cursorHighlights: Highlight[] = []
): TransactionSpec => {
return {
effects: setCursorHighlightsEffect.of(cursorHighlights),
}
}
class CursorMarker extends RectangleMarker {
constructor(
private highlight: Highlight,
className: string,
left: number,
top: number,
width: number | null,
height: number
) {
super(className, left, top, width, height)
}
draw(): HTMLDivElement {
const element = super.draw()
element.style.setProperty('--hue', String(this.highlight.hue))
return element
}
}
// draw the collaborator cursors in a separate layer, so they don't affect word wrapping
const cursorHighlightsLayer = layer({
above: true,
class: 'ol-cm-cursorHighlightsLayer',
update: (update, layer) => {
return (
update.docChanged ||
update.selectionSet ||
update.transactions.some(tr =>
tr.effects.some(effect => effect.is(setCursorHighlightsEffect))
)
)
},
markers(view) {
const markers: CursorMarker[] = []
const highlightRanges = view.state.field(cursorHighlightsState)
const base = getBase(view)
const { from, to } = view.viewport
highlightRanges.between(from, to, (from, to, { highlight }) => {
const pos = fullHeightCoordsAtPos(view, from)
if (pos) {
markers.push(
new CursorMarker(
highlight,
'ol-cm-cursorHighlight',
pos.left - base.left,
pos.top - base.top,
null,
pos.bottom - pos.top
)
)
}
})
return markers
},
})

View File

@@ -0,0 +1,183 @@
import {
EditorSelection,
EditorState,
SelectionRange,
Text,
TransactionSpec,
} from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view'
import { findValidPosition } from '../utils/position'
import customLocalStorage from '../../../infrastructure/local-storage'
import { debugConsole } from '@/utils/debugging'
const buildStorageKey = (docId: string) => `doc.position.${docId}`
/**
* A custom extension that:
* a) stores the cursor position in localStorage when the view is destroyed or the window is closed.
* b) dispatches the cursor position when it changes, for use with “show position in PDF”.
*/
export const cursorPosition = ({
currentDoc: { doc_id: docId },
}: {
currentDoc: { doc_id: string }
}) => {
return [
// store cursor position
ViewPlugin.define(view => {
const unloadListener = () => {
storeCursorPosition(view, docId)
}
window.addEventListener('unload', unloadListener)
return {
destroy: () => {
window.removeEventListener('unload', unloadListener)
unloadListener()
},
}
}),
// Asynchronously dispatch cursor position when the selection changes and
// provide a little debouncing. Using requestAnimationFrame postpones it
// until the next CM6 DOM update.
ViewPlugin.define(view => {
let animationFrameRequest: number | null = null
return {
update(update) {
if (update.selectionSet || update.docChanged) {
if (animationFrameRequest) {
window.cancelAnimationFrame(animationFrameRequest)
}
animationFrameRequest = window.requestAnimationFrame(() => {
animationFrameRequest = null
dispatchCursorPosition(update.state)
})
}
},
}
}),
]
}
// convert the selection head to a row and column
const buildCursorPosition = (state: EditorState) => {
const pos = state.selection.main.head
const line = state.doc.lineAt(pos)
const row = line.number - 1 // 0-indexed
const column = pos - line.from
return { row, column }
}
// dispatch the current cursor position for use with synctex
const dispatchCursorPosition = (state: EditorState) => {
const cursorPosition = buildCursorPosition(state)
window.dispatchEvent(
new CustomEvent('cursor:editor:update', { detail: cursorPosition })
)
}
// store the cursor position for restoring on load
const storeCursorPosition = (view: EditorView, docId: string) => {
const key = buildStorageKey(docId)
const data = customLocalStorage.getItem(key)
const cursorPosition = buildCursorPosition(view.state)
customLocalStorage.setItem(key, { ...data, cursorPosition })
}
// restore the stored cursor position on load
export const restoreCursorPosition = (
doc: Text,
docId: string
): TransactionSpec => {
try {
const key = buildStorageKey(docId)
const data = customLocalStorage.getItem(key)
const { row = 0, column = 0 } = data?.cursorPosition || {}
// restore the cursor to its original position, or the end of the document if past the end
const { lines } = doc
const lineNumber = row < lines ? row + 1 : lines
const line = doc.line(lineNumber)
const offset = line.from + column
const pos = Math.min(offset || 0, doc.length)
return {
selection: EditorSelection.cursor(pos),
}
} catch (error) {
// ignore invalid cursor position
debugConsole.debug('invalid cursor position', error)
return {}
}
}
const dispatchSelectionAndScroll = (
view: EditorView,
selection: SelectionRange
) => {
window.setTimeout(() => {
view.dispatch({
selection,
effects: EditorView.scrollIntoView(selection, { y: 'center' }),
})
view.focus()
})
}
const selectTextIfExists = (doc: Text, pos: number, selectText: string) => {
const selectionLength = pos + selectText.length
const text = doc.sliceString(pos, selectionLength)
return text === selectText
? EditorSelection.range(pos, selectionLength)
: EditorSelection.cursor(doc.lineAt(pos).from)
}
export const setCursorLineAndScroll = (
view: EditorView,
lineNumber: number,
columnNumber = 0,
selectText?: string
) => {
// TODO: map the position through any changes since the previous compile?
let selectionRange
try {
const { doc } = view.state
const pos = findValidPosition(doc, lineNumber, columnNumber)
dispatchSelectionAndScroll(
view,
selectText
? selectTextIfExists(doc, pos, selectText)
: EditorSelection.cursor(pos)
)
} catch (error) {
// ignore invalid cursor position
debugConsole.debug('invalid cursor position', error)
}
if (selectionRange) {
dispatchSelectionAndScroll(view, selectionRange)
}
}
export const setCursorPositionAndScroll = (view: EditorView, pos: number) => {
let selectionRange
try {
pos = Math.min(pos, view.state.doc.length)
selectionRange = EditorSelection.cursor(pos)
} catch (error) {
// ignore invalid cursor position
debugConsole.debug('invalid cursor position', error)
}
if (selectionRange) {
dispatchSelectionAndScroll(view, selectionRange)
}
}

View File

@@ -0,0 +1,24 @@
import { StateEffect, StateField } from '@codemirror/state'
export const docName = (docName: string) =>
StateField.define<string>({
create() {
return docName
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setDocNameEffect)) {
value = effect.value
}
}
return value
},
})
export const setDocNameEffect = StateEffect.define<string>()
export const setDocName = (docName: string) => {
return {
effects: setDocNameEffect.of(docName),
}
}

View File

@@ -0,0 +1,102 @@
import { EditorSelection, Prec } from '@codemirror/state'
import { EditorView, layer } from '@codemirror/view'
import { rectangleMarkerForRange } from '../utils/layer'
import { updateHasMouseDownEffect } from './visual/selection'
import browser from './browser'
/**
* The built-in extension which draws the cursor and selection(s) in layers,
* copied to make use of a custom version of rectangleMarkerForRange which calls
* fullHeightCoordsAtPos when in Source mode, extending the top and bottom
* of the coords to cover the full line height.
*/
export const drawSelection = () => {
return [cursorLayer, selectionLayer, Prec.highest(hideNativeSelection)]
}
const canHidePrimary = !browser.ios
const hideNativeSelection = EditorView.theme({
'.cm-line': {
'caret-color': canHidePrimary ? 'transparent !important' : null,
'& ::selection': {
backgroundColor: 'transparent !important',
},
'&::selection': {
backgroundColor: 'transparent !important',
},
},
})
const cursorLayer = layer({
above: true,
markers(view) {
const {
selection: { ranges, main },
} = view.state
const cursors = []
for (const range of ranges) {
const primary = range === main
if (!range.empty || !primary || canHidePrimary) {
const className = primary
? 'cm-cursor cm-cursor-primary'
: 'cm-cursor cm-cursor-secondary'
const cursor = range.empty
? range
: EditorSelection.cursor(
range.head,
range.head > range.anchor ? -1 : 1
)
for (const piece of rectangleMarkerForRange(view, className, cursor)) {
cursors.push(piece)
}
}
}
return cursors
},
update(update, dom) {
if (update.transactions.some(tr => tr.selection)) {
dom.style.animationName =
dom.style.animationName === 'cm-blink' ? 'cm-blink2' : 'cm-blink'
}
return (
update.docChanged ||
update.selectionSet ||
updateHasMouseDownEffect(update)
)
},
mount(dom, view) {
dom.style.animationDuration = '1200ms'
},
class: 'cm-cursorLayer',
})
const selectionLayer = layer({
above: false,
markers(view) {
const markers = []
for (const range of view.state.selection.ranges) {
if (!range.empty) {
markers.push(
...rectangleMarkerForRange(view, 'cm-selectionBackground', range)
)
}
}
return markers
},
update(update, dom) {
return (
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
updateHasMouseDownEffect(update)
)
},
class: 'cm-selectionLayer',
})

View File

@@ -0,0 +1,29 @@
import { Compartment, EditorState, TransactionSpec } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
const readOnlyConf = new Compartment()
/**
* A custom extension which determines whether the content is editable, by setting the value of the EditorState.readOnly and EditorView.editable facets.
* Commands and extensions read the EditorState.readOnly facet to decide whether they should be applied.
* EditorView.editable determines whether the DOM can be focused, by changing the value of the contenteditable attribute.
*/
export const editable = () => {
return [
readOnlyConf.of([
EditorState.readOnly.of(true),
EditorView.editable.of(false),
]),
]
}
export const setEditable = (value = true): TransactionSpec => {
return {
effects: [
readOnlyConf.reconfigure([
EditorState.readOnly.of(!value),
EditorView.editable.of(value),
]),
],
}
}

View File

@@ -0,0 +1,77 @@
import { StateEffect, StateEffectType, StateField } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
type EffectListenerOptions = {
once: boolean
}
type EffectListener = {
effect: StateEffectType<any>
callback: (value: any) => any
options?: EffectListenerOptions
}
const addEffectListenerEffect = StateEffect.define<EffectListener>()
const removeEffectListenerEffect = StateEffect.define<EffectListener>()
/**
* A state field that stores a collection of listeners per effect,
* and runs each listener's callback function when the effect is dispatched.
*/
export const effectListeners = () => [effectListenersField]
const effectListenersField = StateField.define<EffectListener[]>({
create: () => [],
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(addEffectListenerEffect)) {
value.push(effect.value)
}
if (effect.is(removeEffectListenerEffect)) {
value = value.filter(
listener =>
!(
listener.effect === effect.value.effect &&
listener.callback === effect.value.callback
)
)
}
for (let i = 0; i < value.length; ++i) {
const listener = value[i]
if (effect.is(listener.effect)) {
// Invoke the callback after the transaction
setTimeout(() => listener.callback(effect.value))
if (listener.options?.once) {
// Remove the effectListener
value.splice(i, 1)
// Keep index the same for the next iteration, since we've removed
// an element
--i
}
}
}
}
return value
},
})
export const addEffectListener = <T>(
view: EditorView,
effect: StateEffectType<T>,
callback: (value: T) => any,
options?: EffectListenerOptions
) => {
view.dispatch({
effects: addEffectListenerEffect.of({ effect, callback, options }),
})
}
export const removeEffectListener = <T>(
view: EditorView,
effect: StateEffectType<T>,
callback: (value: T) => any
) => {
view.dispatch({
effects: removeEffectListenerEffect.of({ effect, callback }),
})
}

View File

@@ -0,0 +1,81 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from '@codemirror/view'
import browser from './browser'
class EmptyLineWidget extends WidgetType {
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('span')
element.className = 'ol-cm-filler'
return element
}
eq(widget: EmptyLineWidget) {
return true
}
}
/**
* A custom extension which adds a widget decoration at the start of each empty line in the viewport,
* so that the line is highlighted when part of tracked changes.
*/
export const emptyLineFiller = () => {
if (browser.ios) {
// disable on iOS as it breaks Backspace across empty lines
// https://github.com/overleaf/internal/issues/12192
return []
}
return [
ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged)
this.decorations = this.buildDecorations(update.view)
}
buildDecorations(view: EditorView) {
const decorations = []
const { from, to } = view.viewport
const { doc } = view.state
let pos = from
while (pos <= to) {
const line = doc.lineAt(pos)
if (line.length === 0) {
const decoration = Decoration.widget({
widget: new EmptyLineWidget(),
side: 1,
})
decorations.push(decoration.range(pos))
}
pos = line.to + 1
}
return Decoration.set(decorations)
}
},
{
decorations(value) {
return value.decorations
},
}
),
emptyLineFillerTheme,
]
}
const emptyLineFillerTheme = EditorView.baseTheme({
'.ol-cm-filler': {
padding: '0 2px',
},
})

View File

@@ -0,0 +1,217 @@
import {
ChangeSet,
Extension,
StateEffect,
StateField,
} from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { addEffectListener, removeEffectListener } from './effect-listeners'
import { setMetadataEffect } from './language'
import { debugConsole } from '@/utils/debugging'
type NestedReadonly<T> = {
readonly [P in keyof T]: NestedReadonly<T[P]>
}
type FigureDataProps = {
from: number
to: number
caption: {
from: number
to: number
} | null
label: { from: number; to: number } | null
width?: number
unknownGraphicsArguments?: string
graphicsCommandArguments: {
from: number
to: number
} | null
graphicsCommand: { from: number; to: number }
file: {
from: number
to: number
path: string
}
}
function mapFromTo<T extends { from: number; to: number } | null>(
position: T,
changes: ChangeSet
) {
if (!position) {
return position
}
return {
...position,
from: changes.mapPos(position.from),
to: changes.mapPos(position.to),
}
}
export class FigureData {
// eslint-disable-next-line no-useless-constructor
constructor(private props: NestedReadonly<FigureDataProps>) {}
public get from() {
return this.props.from
}
public get to() {
return this.props.to
}
public get caption() {
return this.props.caption
}
public get label() {
return this.props.label
}
public get width() {
return this.props.width
}
public get unknownGraphicsArguments() {
return this.props.unknownGraphicsArguments
}
public get graphicsCommandArguments() {
return this.props.graphicsCommandArguments
}
public get graphicsCommand() {
return this.props.graphicsCommand
}
public get file() {
return this.props.file
}
map(changes: ChangeSet): FigureData {
return new FigureData({
from: changes.mapPos(this.from),
to: changes.mapPos(this.to),
caption: mapFromTo(this.caption, changes),
label: mapFromTo(this.label, changes),
graphicsCommand: mapFromTo(this.graphicsCommand, changes),
width: this.width,
file: mapFromTo(this.file, changes),
graphicsCommandArguments: mapFromTo(
this.graphicsCommandArguments,
changes
),
unknownGraphicsArguments: this.unknownGraphicsArguments,
})
}
}
export const editFigureDataEffect = StateEffect.define<FigureData | null>()
export const editFigureData = StateField.define<FigureData | null>({
create: () => null,
update: (current, transaction) => {
let value: FigureData | null | undefined
for (const effect of transaction.effects) {
if (effect.is(editFigureDataEffect)) {
value = effect.value
}
}
// Allow setting to null
if (value !== undefined) {
return value
}
if (!current) {
return current
}
return current.map(transaction.changes)
},
})
export const figureModal = (): Extension => [editFigureData]
export function waitForFileTreeUpdate(view: EditorView) {
const abortController = new AbortController()
const promise = new Promise<void>(resolve => {
const abort = () => {
debugConsole.warn('Aborting wait for file tree update')
removeEffectListener(view, setMetadataEffect, listener)
resolve()
}
function listener() {
if (abortController.signal.aborted) {
// We've already handled this
return
}
abortController.signal.removeEventListener('abort', abort)
resolve()
}
abortController.signal.addEventListener('abort', abort, { once: true })
addEffectListener(view, setMetadataEffect, listener, { once: true })
})
return {
withTimeout(afterMs = 500) {
setTimeout(() => abortController.abort(), afterMs)
return promise
},
promise,
}
}
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'application/pdf',
])
export type PastedImageData = {
name: string
type: string
data: Blob
}
export const figureModalPasteHandler = (): Extension => {
return EditorView.domEventHandlers({
drop: evt => {
if (!evt.dataTransfer || evt.dataTransfer.files.length === 0) {
return
}
const file = evt.dataTransfer.files[0]
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return
}
window.dispatchEvent(
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
detail: {
name: file.name,
type: file.type,
data: file,
},
})
)
},
paste: evt => {
if (!evt.clipboardData || evt.clipboardData.files.length === 0) {
return
}
if (evt.clipboardData.types.includes('text/plain')) {
return // allow pasted text to be handled even if there's also a file on the clipboard
}
const file = evt.clipboardData.files[0]
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return
}
window.dispatchEvent(
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
detail: {
name: file.name,
type: file.type,
data: file,
},
})
)
},
})
}

View File

@@ -0,0 +1,89 @@
import { EditorView } from '@codemirror/view'
import { hasImageExtension } from '@/features/source-editor/utils/file'
import { FigureModalSource } from '@/features/source-editor/components/figure-modal/figure-modal-context'
import { EditorSelection } from '@codemirror/state'
export const fileTreeItemDrop = () =>
EditorView.domEventHandlers({
dragover(event) {
// TODO: detect a drag from the file tree?
if (event.dataTransfer) {
event.preventDefault()
}
},
drop(event, view) {
if (event.dataTransfer) {
const fileId = event.dataTransfer.getData(
'application/x-overleaf-file-id'
)
const filePath = event.dataTransfer.getData(
'application/x-overleaf-file-path'
)
if (fileId && filePath) {
event.preventDefault()
const pos = view.posAtCoords(event)
if (pos !== null) {
handleDroppedFile(view, pos, fileId, filePath)
}
}
}
},
})
const withoutExtension = (filename: string) =>
filename.substring(0, filename.lastIndexOf('.'))
const handleDroppedFile = (
view: EditorView,
pos: number,
fileId: string,
filePath: string
) => {
if (filePath.endsWith('.bib')) {
view.focus()
const insert = `\\bibliography{${withoutExtension(filePath)}}`
view.dispatch({
changes: { from: pos, insert },
selection: EditorSelection.cursor(pos + insert.length),
})
return
}
if (filePath.endsWith('.tex')) {
view.focus()
const insert = `\\input{${withoutExtension(filePath)}}`
view.dispatch({
changes: { from: pos, insert },
selection: EditorSelection.cursor(pos + insert.length),
})
return
}
if (hasImageExtension(filePath)) {
view.focus()
view.dispatch({
selection: EditorSelection.cursor(pos),
})
window.dispatchEvent(
new CustomEvent('figure-modal:open', {
detail: {
source: FigureModalSource.FILE_TREE,
fileId,
filePath,
},
})
)
return
}
return null
}

View File

@@ -0,0 +1,38 @@
import { ChangeSpec, EditorState, Transaction } from '@codemirror/state'
const BAD_CHARS_REGEXP = /[\0\uD800-\uDFFF]/g
const BAD_CHARS_REPLACEMENT_CHAR = '\uFFFD'
/**
* A custom extension that replaces input characters in a Unicode range with a replacement character.
*/
export const filterCharacters = () => {
return EditorState.transactionFilter.of(tr => {
if (tr.docChanged && !tr.annotation(Transaction.remote)) {
const changes: ChangeSpec[] = []
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const text = inserted.toString()
const newText = text.replaceAll(
BAD_CHARS_REGEXP,
BAD_CHARS_REPLACEMENT_CHAR
)
if (newText !== text) {
changes.push({
from: fromB,
to: toB,
insert: newText,
})
}
})
if (changes.length) {
return [tr, { changes, sequential: true }]
}
}
return tr
})
}

View File

@@ -0,0 +1,19 @@
import { foldAll, toggleFold, unfoldAll } from '@codemirror/language'
/**
* A custom extension that binds keyboard shortcuts to folding actions.
*/
export const foldingKeymap = [
{
key: 'F2',
run: toggleFold,
},
{
key: 'Alt-Shift-1',
run: foldAll,
},
{
key: 'Alt-Shift-0',
run: unfoldAll,
},
]

View File

@@ -0,0 +1,33 @@
import { ViewPlugin } from '@codemirror/view'
import { StateEffect } from '@codemirror/state'
import { updateHasEffect } from '../utils/effects'
const fontLoadEffect = StateEffect.define<readonly FontFace[]>()
export const hasFontLoadedEffect = updateHasEffect(fontLoadEffect)
/**
* A custom extension that listens for an event indicating that fonts have finished loading,
* then dispatches an effect which other extensions can use to recalculate values.
*/
export const fontLoad = ViewPlugin.define(view => {
function listener(this: FontFaceSet, event: FontFaceSetLoadEvent) {
view.dispatch({ effects: fontLoadEffect.of(event.fontfaces) })
}
const fontLoadSupport = 'fonts' in document
if (fontLoadSupport) {
// TypeScript doesn't appear to know the correct type for the listener
document.fonts.addEventListener('loadingdone', listener as EventListener)
}
return {
destroy() {
if (fontLoadSupport) {
document.fonts.removeEventListener(
'loadingdone',
listener as EventListener
)
}
},
}
})

View File

@@ -0,0 +1,13 @@
import { EditorView } from '@codemirror/view'
/**
* An extension that triggers a custom DOM event whenever the editor geometry
* changes. This is used to synchronize the editor content and review panel
* height in "Current file" mode.
*/
export const geometryChangeEvent = () =>
EditorView.updateListener.of(update => {
if (update.geometryChanged) {
window.dispatchEvent(new CustomEvent('editor:geometry-change'))
}
})

View File

@@ -0,0 +1,67 @@
import { Prec } from '@codemirror/state'
import { EditorView, keymap } from '@codemirror/view'
import { gotoLine } from '@codemirror/search'
/**
* A custom extension that provides a keyboard shortcut
* and panel with UI for jumping to a specific line number.
*/
export const goToLinePanel = () => {
return [
Prec.high(
keymap.of([
{
key: 'Mod-Shift-l',
preventDefault: true,
run: gotoLine,
},
])
),
gotoLineTheme,
]
}
const gotoLineTheme = EditorView.baseTheme({
'.cm-panel.cm-gotoLine': {
padding: '10px',
fontSize: '14px',
'& label': {
margin: 0,
fontSize: '14px',
'& .cm-textfield': {
margin: '0 10px',
maxWidth: '100px',
height: '34px',
padding: '5px 16px',
fontSize: '14px',
fontWeight: 'normal',
lineHeight: 'var(--line-height-base)',
color: 'var(--input-color)',
backgroundColor: '#fff',
backgroundImage: 'none',
borderRadius: 'var(--input-border-radius)',
boxShadow: 'inset 0 1px 1px rgb(0 0 0 / 8%)',
transition:
'border-color ease-in-out .15s, box-shadow ease-in-out .15s',
'&:focus-visible': {
outline: 'none',
},
'&:focus': {
borderColor: 'var(--input-border-focus)',
},
},
},
'& .cm-button': {
padding: '4px 16px 5px',
textTransform: 'capitalize',
fontSize: '14px',
lineHeight: 'var(--line-height-base)',
userSelect: 'none',
backgroundImage: 'none',
backgroundColor: 'var(--btn-default-bg)',
borderRadius: 'var(--btn-border-radius-base)',
border: '0 solid transparent',
color: '#fff',
},
},
})

View File

@@ -0,0 +1,122 @@
import {
Decoration,
DecorationSet,
EditorView,
layer,
LayerMarker,
RectangleMarker,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { sourceOnly } from './visual/visual'
import { fullHeightCoordsAtPos } from '../utils/layer'
/**
* An alternative version of the built-in highlightActiveLine extension,
* using a custom approach for highlighting the full height of the active “visual line” of a wrapped line.
*/
export const highlightActiveLine = (visual: boolean) => {
// this extension should only be active in the source editor
return sourceOnly(visual, [
activeLineLayer,
singleLineHighlighter,
highlightActiveLineTheme,
])
}
const highlightActiveLineTheme = EditorView.baseTheme({
'.ol-cm-activeLineLayer': {
pointerEvents: 'none',
},
})
/**
* Line decoration approach used for non-wrapped lines, adapted from built-in
* CodeMirror 6 highlightActiveLine, licensed under the MIT license:
* https://github.com/codemirror/view/blob/main/src/active-line.ts
*/
const lineDeco = Decoration.line({ class: 'cm-activeLine' })
const singleLineHighlighter = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.getDeco(view)
}
update(update: ViewUpdate) {
if (update.geometryChanged || update.selectionSet) {
this.decorations = this.getDeco(update.view)
}
}
getDeco(view: EditorView) {
const deco = []
// NOTE: only highlighting the active line for the main selection
const { main } = view.state.selection
// No active line highlight when text is selected
if (main.empty) {
const line = view.lineBlockAt(main.head)
if (line.height <= view.defaultLineHeight) {
deco.push(lineDeco.range(line.from))
}
}
return Decoration.set(deco)
}
},
{
decorations: v => v.decorations,
}
)
// Custom layer approach, used only for wrapped lines
const activeLineLayer = layer({
above: false,
class: 'ol-cm-activeLineLayer',
markers(view: EditorView): readonly LayerMarker[] {
const markers: LayerMarker[] = []
// NOTE: only highlighting the active line for the main selection
const { main } = view.state.selection
// no active line highlight when text is selected
if (!main.empty) {
return markers
}
// Use line decoration when line doesn't wrap
if (view.lineBlockAt(main.head).height <= view.defaultLineHeight) {
return markers
}
const coords = fullHeightCoordsAtPos(
view,
main.head,
main.assoc || undefined
)
if (coords) {
const scrollRect = view.scrollDOM.getBoundingClientRect()
const contentRect = view.contentDOM.getBoundingClientRect()
const scrollTop = view.scrollDOM.scrollTop
const top = coords.top - scrollRect.top + scrollTop
const left = contentRect.left - scrollRect.left
const width = contentRect.right - contentRect.left
const height = coords.bottom - coords.top
markers.push(
new RectangleMarker('cm-activeLine', left, top, width, height)
)
}
return markers
},
update(update: ViewUpdate): boolean {
return update.geometryChanged || update.selectionSet
},
})

View File

@@ -0,0 +1,18 @@
import { sourceOnly } from './visual/visual'
import { highlightSpecialChars as _highlightSpecialChars } from '@codemirror/view'
/**
* The built-in extension which highlights unusual whitespace characters,
* configured to highlight additional space characters.
*/
export const highlightSpecialChars = (visual: boolean) =>
sourceOnly(
visual,
_highlightSpecialChars({
addSpecialChars: new RegExp(
// non standard space characters (https://jkorpela.fi/chars/spaces.html)
'[\u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u200B\u202F\u205F\u3000\uFEFF]',
/x/.unicode != null ? 'gu' : 'g'
),
})
)

View File

@@ -0,0 +1,17 @@
import { Extension } from '@codemirror/state'
import { indentationMarkers as markers } from '@replit/codemirror-indentation-markers'
import { sourceOnly } from './visual/visual'
import browser from './browser'
/**
* A third-party extension which adds markers to show the indentation level.
* Configured to omit markers in the first column and to keep the same style for markers in the active block.
*/
export const indentationMarkers = (visual: boolean): Extension => {
// disable indentation markers in Safari due to flicker, ref to git issue: 18263
return browser.safari
? []
: sourceOnly(visual, [
markers({ hideFirstIndent: true, highlightActiveBlock: false }),
])
}

View File

@@ -0,0 +1,166 @@
import {
EditorView,
rectangularSelection,
tooltips,
crosshairCursor,
dropCursor,
highlightActiveLineGutter,
} from '@codemirror/view'
import { EditorState, Extension } from '@codemirror/state'
import { foldGutter, indentOnInput, indentUnit } from '@codemirror/language'
import { history } from '@codemirror/commands'
import { language } from './language'
import { lineWrappingIndentation } from './line-wrapping-indentation'
import { theme } from './theme'
import { realtime } from './realtime'
import { cursorPosition } from './cursor-position'
import { scrollPosition } from './scroll-position'
import { annotations } from './annotations'
import { cursorHighlights } from './cursor-highlights'
import { autoComplete } from './auto-complete'
import { editable } from './editable'
import { autoPair } from './auto-pair'
import { phrases } from './phrases'
import { spelling } from './spelling'
import { symbolPalette } from './symbol-palette'
import { search } from './search'
import { filterCharacters } from './filter-characters'
import { keybindings } from './keybindings'
import { bracketMatching, bracketSelection } from './bracket-matching'
import { verticalOverflow } from './vertical-overflow'
import { thirdPartyExtensions } from './third-party-extensions'
import { lineNumbers } from './line-numbers'
import { highlightActiveLine } from './highlight-active-line'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { emptyLineFiller } from './empty-line-filler'
import { goToLinePanel } from './go-to-line'
import { drawSelection } from './draw-selection'
import { visual } from './visual/visual'
import { inlineBackground } from './inline-background'
import { indentationMarkers } from './indentation-markers'
import { codemirrorDevTools } from '../languages/latex/codemirror-dev-tools'
import { keymaps } from './keymaps'
import { shortcuts } from './shortcuts'
import { effectListeners } from './effect-listeners'
import { highlightSpecialChars } from './highlight-special-chars'
import { toolbarPanel } from './toolbar/toolbar-panel'
import { breadcrumbPanel } from './breadcrumbs-panel'
import { geometryChangeEvent } from './geometry-change-event'
import { docName } from './doc-name'
import { fileTreeItemDrop } from './file-tree-item-drop'
import { mathPreview } from './math-preview'
import { ranges } from './ranges'
import { trackDetachedComments } from './track-detached-comments'
import { reviewTooltip } from './review-tooltip'
const moduleExtensions: Array<(options: Record<string, any>) => Extension> =
importOverleafModules('sourceEditorExtensions').map(
(item: { import: { extension: Extension } }) => item.import.extension
)
export const createExtensions = (options: Record<string, any>): Extension[] => [
lineNumbers(),
highlightSpecialChars(options.visual.visual),
// The built-in extension that manages the history stack,
// configured to increase the maximum delay between adjacent grouped edits
history({ newGroupDelay: 250 }),
// The built-in extension that displays buttons for folding code in a gutter element,
// configured with custom openText and closeText symbols.
foldGutter({
openText: '▾',
closedText: '▸',
}),
drawSelection(),
// A built-in facet that is set to true to allow multiple selections.
// This makes the editor more like a code editor than Google Docs or Microsoft Word,
// which only have single selections.
EditorState.allowMultipleSelections.of(true),
// A built-in extension that enables soft line wrapping.
EditorView.lineWrapping,
// A built-in extension that re-indents input if the language defines an indentOnInput field in its language data.
indentOnInput(),
lineWrappingIndentation(options.visual.visual),
indentationMarkers(options.visual.visual),
bracketMatching(),
bracketSelection(),
// A built-in extension that enables rectangular selections, created by dragging a new selection while holding down Alt.
rectangularSelection(),
// A built-in extension that turns the pointer into a crosshair while Alt is pressed.
crosshairCursor(),
// A built-in extension that shows where dragged content will be dropped.
dropCursor(),
// A built-in extension that is used for configuring tooltip behaviour,
// configured so that the tooltip parent is the document body,
// to avoid cutting off tooltips which overflow the editor.
tooltips({
parent: document.body,
tooltipSpace(view) {
const { top, bottom } = view.scrollDOM.getBoundingClientRect()
return {
top,
left: 0,
bottom,
right: window.innerWidth,
}
},
}),
keymaps,
goToLinePanel(),
filterCharacters(),
// NOTE: `autoComplete` needs to be before `keybindings` so that arrow key handling
// in the autocomplete pop-up takes precedence over Vim/Emacs key bindings
autoComplete({
enabled: options.settings.autoComplete,
projectFeatures: options.projectFeatures,
referencesSearchMode: options.settings.referencesSearchMode,
}),
// NOTE: `keybindings` needs to be before `language` so that Vim/Emacs bindings take
// precedence over language-specific keyboard shortcuts
keybindings(),
docName(options.docName),
// NOTE: `annotations` needs to be before `language`
annotations(),
language(options.docName, options.metadata, options.settings),
indentUnit.of(' '), // 4 spaces
theme(options.theme),
realtime(options.currentDoc, options.handleError),
cursorPosition(options.currentDoc),
scrollPosition(options.currentDoc, options.visual),
cursorHighlights(),
autoPair(options.settings),
editable(),
search(),
phrases(options.phrases),
spelling(options.spelling),
shortcuts,
symbolPalette(),
// NOTE: `emptyLineFiller` needs to be before `trackChanges`,
// so the decorations are added in the correct order.
emptyLineFiller(),
ranges(),
trackDetachedComments(options.currentDoc),
visual(options.visual),
mathPreview(options.settings.mathPreview),
reviewTooltip(),
toolbarPanel(),
breadcrumbPanel(),
verticalOverflow(),
highlightActiveLine(options.visual.visual),
// The built-in extension that highlights the active line in the gutter.
highlightActiveLineGutter(),
inlineBackground(options.visual.visual),
codemirrorDevTools(),
// Send exceptions to Sentry
EditorView.exceptionSink.of(options.handleException),
// CodeMirror extensions provided by modules
moduleExtensions.map(extension => extension(options)),
thirdPartyExtensions(),
effectListeners(),
geometryChangeEvent(),
fileTreeItemDrop(),
]

View File

@@ -0,0 +1,98 @@
import { Annotation, Compartment } from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view'
import { themeOptionsChange } from './theme'
import { sourceOnly } from './visual/visual'
import { round } from 'lodash'
import { hasLanguageLoadedEffect } from './language'
import { fontLoad, hasFontLoadedEffect } from './font-load'
const themeConf = new Compartment()
const changeHalfLeadingAnnotation = Annotation.define<boolean>()
function firstVisibleNonSpacePos(view: EditorView) {
for (const range of view.visibleRanges) {
const match = /\S/.exec(view.state.sliceDoc(range.from, range.to))
if (match) {
return range.from + match.index
}
}
return null
}
function measureHalfLeading(view: EditorView) {
const pos = firstVisibleNonSpacePos(view)
if (pos === null) {
return 0
}
const coords = view.coordsAtPos(pos)
if (!coords) {
return 0
}
const inlineBoxHeight = coords.bottom - coords.top
// Rounding prevents gaps appearing in some situations
return round((view.defaultLineHeight - inlineBoxHeight) / 2, 2)
}
function createTheme(halfLeading: number) {
return EditorView.contentAttributes.of({
style: `--half-leading: ${halfLeading}px`,
})
}
/**
* A custom extension which measures the height of the first non-space position and provides a CSS variable via an editor theme,
* used for extending elements over the whole line height using padding.
*/
const plugin = ViewPlugin.define(
view => {
let halfLeading = 0
const measureRequest = {
read: () => {
return measureHalfLeading(view)
},
write: (newHalfLeading: number) => {
if (newHalfLeading !== halfLeading) {
halfLeading = newHalfLeading
window.setTimeout(() =>
view.dispatch({
effects: themeConf.reconfigure(createTheme(newHalfLeading)),
annotations: changeHalfLeadingAnnotation.of(true),
})
)
}
},
}
return {
update(update) {
// Ignore any update triggered by this plugin
if (
update.transactions.some(tr =>
tr.annotation(changeHalfLeadingAnnotation)
)
) {
return
}
if (
hasFontLoadedEffect(update) ||
(update.geometryChanged && !update.docChanged) ||
update.transactions.some(tr => tr.annotation(themeOptionsChange)) ||
hasLanguageLoadedEffect(update)
) {
view.requestMeasure(measureRequest)
}
},
}
},
{
provide: () => [themeConf.of(createTheme(0))],
}
)
export const inlineBackground = (visual: boolean) => {
return sourceOnly(visual, [fontLoad, plugin])
}

View File

@@ -0,0 +1,251 @@
import { openSearchPanel } from '@codemirror/search'
import {
Compartment,
EditorSelection,
Prec,
TransactionSpec,
} from '@codemirror/state'
import type { EmacsHandler } from '@replit/codemirror-emacs'
import type { CodeMirror, Vim } from '@replit/codemirror-vim'
import { foldCode, toggleFold, unfoldCode } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import {
cursorToBeginningOfVisualLine,
cursorToEndOfVisualLine,
selectRestOfVisualLine,
selectToBeginningOfVisualLine,
selectToEndOfVisualLine,
} from './visual-line-selection'
const hasNonEmptySelection = (cm: CodeMirror): boolean => {
const selections = cm.getSelections()
return selections.some(selection => selection.length)
}
type VimCodeMirrorCommands = typeof CodeMirror.commands & {
save: (cm: CodeMirror) => void
}
let customisedVim = false
const customiseVimOnce = (_Vim: typeof Vim, _CodeMirror: typeof CodeMirror) => {
if (customisedVim) {
return
}
// Allow copy via Ctrl-C in insert mode
_Vim.unmap('<C-c>', 'insert')
_Vim.defineAction(
'insertModeCtrlC',
(cm: CodeMirror, actionArgs: object, state: any) => {
if (hasNonEmptySelection(cm)) {
navigator.clipboard.writeText(cm.getSelection())
cm.setSelection(cm.getCursor(), cm.getCursor())
} else {
_Vim.exitInsertMode(cm)
}
}
)
// Overwrite the moveByCharacters command with a decoration-aware version
_Vim.defineMotion(
'moveByCharacters',
function (
cm: CodeMirror,
head: { line: number; ch: number },
motionArgs: Record<string, unknown>
) {
const { cm6: view } = cm
const repeat = Math.min(Number(motionArgs.repeat), view.state.doc.length)
const forward = Boolean(motionArgs.forward)
// head.line is 0-indexed
const startLine = view.state.doc.line(head.line + 1)
let cursor = EditorSelection.cursor(startLine.from + head.ch)
for (let i = 0; i < repeat; ++i) {
cursor = view.moveByChar(cursor, forward)
}
const finishLine = view.state.doc.lineAt(cursor.head)
return new _CodeMirror.Pos(
finishLine.number - 1,
cursor.head - finishLine.from
)
}
)
_Vim.mapCommand('<C-c>', 'action', 'insertModeCtrlC', undefined, {
context: 'insert',
})
// Code folding commands
_Vim.defineAction('toggleFold', function (cm: CodeMirror) {
toggleFold(cm.cm6)
})
_Vim.mapCommand('za', 'action', 'toggleFold')
_Vim.defineAction('foldCode', function (cm: CodeMirror) {
foldCode(cm.cm6)
})
_Vim.mapCommand('zc', 'action', 'foldCode')
_Vim.defineAction('unfoldCode', function (cm: CodeMirror) {
unfoldCode(cm.cm6)
})
// disable tab and shift-tab keys in command (normal) and visual modes
// using "undefined" params because mapCommand signature is:
// mapCommand(keys, type, name, args, extra)
_Vim.mapCommand('<Tab>', undefined, undefined, undefined, {
context: 'normal',
})
_Vim.mapCommand('<Tab>', undefined, undefined, undefined, {
context: 'visual',
})
_Vim.mapCommand('<S-Tab>', undefined, undefined, undefined, {
context: 'normal',
})
_Vim.mapCommand('<S-Tab>', undefined, undefined, undefined, {
context: 'visual',
})
// Make the Vim 'write' command start a compile
;(_CodeMirror.commands as VimCodeMirrorCommands).save = () => {
window.dispatchEvent(new Event('pdf:recompile'))
}
customisedVim = true
}
// Used to ensure that only one listener is active
let emacsSearchCloseListener: (() => void) | undefined
let customisedEmacs = false
const customiseEmacsOnce = (_EmacsHandler: typeof EmacsHandler) => {
if (customisedEmacs) {
return
}
customisedEmacs = true
const jumpToLastMark = (handler: EmacsHandler) => {
const mark = handler.popEmacsMark()
if (!mark || !mark.length) {
return
}
let selection = null
if (mark.length >= 2) {
selection = EditorSelection.range(mark[0], mark[1])
} else {
selection = EditorSelection.cursor(mark[0])
}
handler.view.dispatch({ selection, scrollIntoView: true })
}
_EmacsHandler.addCommands({
openSearch(handler: EmacsHandler) {
const mark = handler.view.state.selection.main
handler.pushEmacsMark([mark.anchor, mark.head])
openSearchPanel(handler.view)
if (emacsSearchCloseListener) {
document.removeEventListener(
'cm:emacs-close-search-panel',
emacsSearchCloseListener
)
}
emacsSearchCloseListener = () => {
jumpToLastMark(handler)
}
document.addEventListener(
'cm:emacs-close-search-panel',
emacsSearchCloseListener
)
},
save() {
window.dispatchEvent(new Event('pdf:recompile'))
},
})
_EmacsHandler.bindKey('C-s', 'openSearch')
_EmacsHandler.bindKey('C-r', 'openSearch')
_EmacsHandler.bindKey('C-x C-s', 'save')
_EmacsHandler.bindKey('C-a', {
command: 'goOrSelect',
args: [cursorToBeginningOfVisualLine, selectToBeginningOfVisualLine],
})
_EmacsHandler.bindKey('C-e', {
command: 'goOrSelect',
args: [cursorToEndOfVisualLine, selectToEndOfVisualLine],
})
_EmacsHandler.bindKey('C-k', {
command: 'killLine',
args: selectRestOfVisualLine,
})
}
const options = [
{
name: 'default',
load: async () => {
// TODO: load default keybindings?
return []
},
},
{
name: 'vim',
load: () =>
import(
/* webpackChunkName: "codemirror-vim" */ '@replit/codemirror-vim'
).then(m => {
customiseVimOnce(m.Vim, m.CodeMirror)
return m.vim()
}),
},
{
name: 'emacs',
load: () =>
import(
/* webpackChunkName: "codemirror-emacs" */ '@replit/codemirror-emacs'
).then(m => {
customiseEmacsOnce(m.EmacsHandler)
return [
m.emacs(),
EditorView.domEventHandlers({
keydown(event) {
if (event.ctrlKey && event.key === 's') {
event.stopPropagation()
}
},
}),
]
}),
},
]
const keybindingsConf = new Compartment()
/**
* Third-party extensions providing Emacs and Vim keybindings,
* implemented as wrappers around the CodeMirror 5 interface,
* with some customisation (particularly related to search).
*/
export const keybindings = () => {
return keybindingsConf.of(Prec.highest([]))
}
export const setKeybindings = async (
selectedKeybindings = 'default'
): Promise<TransactionSpec> => {
if (selectedKeybindings === 'none') {
selectedKeybindings = 'default'
}
const selectedOption = options.find(
option => option.name === selectedKeybindings
)
if (!selectedOption) {
throw new Error(`No key bindings found with name ${selectedKeybindings}`)
}
const support = await selectedOption.load()
return {
// NOTE: use Prec.highest as this keybinding must be above the default keymap(s)
effects: keybindingsConf.reconfigure(Prec.highest(support)),
}
}

View File

@@ -0,0 +1,50 @@
import { keymap } from '@codemirror/view'
import { defaultKeymap, historyKeymap } from '@codemirror/commands'
import { lintKeymap } from '@codemirror/lint'
import { scrollOneLineKeymap } from './scroll-one-line'
import { foldingKeymap } from './folding-keymap'
const ignoredDefaultKeybindings = new Set([
// NOTE: disable "Mod-Enter" as it's used for "Compile"
'Mod-Enter',
// Disable Alt+Arrow as we have special behaviour on Windows / Linux
'Alt-ArrowLeft',
'Alt-ArrowRight',
// This keybinding causes issues on some keyboard layouts where \ is entered
// using AltGr. Windows treats Ctrl-Alt as AltGr, so trying to insert a \
// with Ctrl-Alt would trigger this keybinding, rather than inserting a \
'Mod-Alt-\\',
])
const ignoredDefaultMacKeybindings = new Set([
// We replace these with our custom visual-line versions
'Mod-Backspace',
'Mod-Delete',
])
const filteredDefaultKeymap = defaultKeymap.filter(
// We only filter on keys, so if the keybinding doesn't have a key,
// allow it
item => {
if (item.key && ignoredDefaultKeybindings.has(item.key)) {
return false
}
if (item.mac && ignoredDefaultMacKeybindings.has(item.mac)) {
return false
}
return true
}
)
export const keymaps = keymap.of([
// The default CodeMirror keymap, with a few key bindings filtered out.
...filteredDefaultKeymap,
// Key bindings for undo/redo/undoSelection/redoSelection
...historyKeymap,
// Key bindings for “open lint panel” and “next diagnostic”
...lintKeymap,
// Key bindings for folding actions
...foldingKeymap,
// Key bindings for scrolling the viewport
...scrollOneLineKeymap,
])

Some files were not shown because too many files have changed in this diff Show More