first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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={
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
</>
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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 there’s 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',
|
||||
},
|
||||
})
|
||||
@@ -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)',
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')],
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
])
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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),
|
||||
]),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
),
|
||||
})
|
||||
)
|
||||
@@ -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 }),
|
||||
])
|
||||
}
|
||||
@@ -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(),
|
||||
]
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user