first commit
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
|
||||
import { Command, EditorView } from '@codemirror/view'
|
||||
import {
|
||||
closeSearchPanel,
|
||||
openSearchPanel,
|
||||
searchPanelOpen,
|
||||
} from '@codemirror/search'
|
||||
import { toggleRanges, wrapRanges } from '../../commands/ranges'
|
||||
import {
|
||||
ancestorListType,
|
||||
toggleListForRanges,
|
||||
unwrapBulletList,
|
||||
unwrapDescriptionList,
|
||||
unwrapNumberedList,
|
||||
wrapInBulletList,
|
||||
wrapInDescriptionList,
|
||||
wrapInNumberedList,
|
||||
} from './lists'
|
||||
import { snippet } from '@codemirror/autocomplete'
|
||||
import { snippets } from './snippets'
|
||||
import { minimumListDepthForSelection } from '../../utils/tree-operations/ancestors'
|
||||
import { isVisual } from '../visual/visual'
|
||||
import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
||||
|
||||
export const toggleBold = toggleRanges('\\textbf')
|
||||
export const toggleItalic = toggleRanges('\\textit')
|
||||
|
||||
// TODO: apply as a snippet?
|
||||
// TODO: read URL from clipboard?
|
||||
export const wrapInHref = wrapRanges('\\href{}{', '}', false, (range, view) =>
|
||||
isVisual(view) ? range : EditorSelection.cursor(range.from - 2)
|
||||
)
|
||||
export const toggleBulletList = toggleListForRanges('itemize')
|
||||
export const toggleNumberedList = toggleListForRanges('enumerate')
|
||||
export const wrapInInlineMath = wrapRanges('\\(', '\\)')
|
||||
export const wrapInDisplayMath = wrapRanges('\n\\[', '\\]\n')
|
||||
|
||||
export const ensureEmptyLine = (
|
||||
state: EditorState,
|
||||
range: SelectionRange,
|
||||
direction: 'above' | 'below' = 'below'
|
||||
) => {
|
||||
let pos = range.anchor
|
||||
let suffix = ''
|
||||
let prefix = ''
|
||||
|
||||
const line = state.doc.lineAt(pos)
|
||||
|
||||
if (line.text.trim().length) {
|
||||
if (direction === 'below') {
|
||||
pos = Math.min(line.to + 1, state.doc.length)
|
||||
} else {
|
||||
pos = Math.max(line.from - 1, 0)
|
||||
}
|
||||
const neighbouringLine = state.doc.lineAt(pos)
|
||||
|
||||
if (neighbouringLine.length && direction === 'below') {
|
||||
suffix = '\n'
|
||||
} else if (neighbouringLine.length && direction === 'above') {
|
||||
prefix = '\n'
|
||||
}
|
||||
}
|
||||
return { pos, suffix, prefix }
|
||||
}
|
||||
|
||||
export const insertFigure: Command = view => {
|
||||
const { state, dispatch } = view
|
||||
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
|
||||
const template = `\n${snippets.figure}\n${suffix}`
|
||||
snippet(template)({ state, dispatch }, { label: 'Figure' }, pos, pos)
|
||||
return true
|
||||
}
|
||||
|
||||
export const insertTable = (view: EditorView, sizeX: number, sizeY: number) => {
|
||||
const { state, dispatch } = view
|
||||
const visual = isVisual(view)
|
||||
const placeholder = visual ? '' : '#{}'
|
||||
const placeholderAtStart = visual ? '#{}' : ''
|
||||
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
|
||||
const template = `${placeholderAtStart}\n\\begin{table}
|
||||
\t\\centering
|
||||
\t\\begin{tabular}{${'c'.repeat(sizeX)}}
|
||||
${(
|
||||
'\t\t' +
|
||||
`${placeholder} & ${placeholder}`.repeat(sizeX - 1) +
|
||||
'\\\\\n'
|
||||
).repeat(sizeY)}\t\\end{tabular}
|
||||
\t\\caption{Caption}
|
||||
\t\\label{tab:my_label}
|
||||
\\end{table}${suffix}`
|
||||
snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
|
||||
return true
|
||||
}
|
||||
|
||||
export const insertCite: Command = view => {
|
||||
const { state, dispatch } = view
|
||||
const pos = state.selection.main.anchor
|
||||
const template = snippets.cite
|
||||
snippet(template)({ state, dispatch }, { label: 'Cite' }, pos, pos)
|
||||
return true
|
||||
}
|
||||
|
||||
export const insertRef: Command = view => {
|
||||
const { state, dispatch } = view
|
||||
const pos = state.selection.main.anchor
|
||||
const template = snippets.ref
|
||||
snippet(template)({ state, dispatch }, { label: 'Ref' }, pos, pos)
|
||||
return true
|
||||
}
|
||||
|
||||
export const indentDecrease: Command = view => {
|
||||
if (minimumListDepthForSelection(view.state) < 2) {
|
||||
return false
|
||||
}
|
||||
switch (ancestorListType(view.state)) {
|
||||
case 'itemize':
|
||||
return unwrapBulletList(view)
|
||||
case 'enumerate':
|
||||
return unwrapNumberedList(view)
|
||||
case 'description':
|
||||
return unwrapDescriptionList(view)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const cursorIsAtStartOfListItem = (state: EditorState) => {
|
||||
return state.selection.ranges.every(range => {
|
||||
const line = state.doc.lineAt(range.from)
|
||||
const prefix = state.sliceDoc(line.from, range.from)
|
||||
return /\\item\s*$/.test(prefix)
|
||||
})
|
||||
}
|
||||
|
||||
export const indentIncrease: Command = view => {
|
||||
if (minimumListDepthForSelection(view.state) < 1) {
|
||||
return false
|
||||
}
|
||||
switch (ancestorListType(view.state)) {
|
||||
case 'itemize':
|
||||
return wrapInBulletList(view)
|
||||
case 'enumerate':
|
||||
return wrapInNumberedList(view)
|
||||
case 'description':
|
||||
return wrapInDescriptionList(view)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleSearch: Command = view => {
|
||||
if (searchPanelOpen(view.state)) {
|
||||
closeSearchPanel(view)
|
||||
} else {
|
||||
sendSearchEvent('search-open', {
|
||||
searchType: 'document',
|
||||
method: 'button',
|
||||
location: 'toolbar',
|
||||
mode: isVisual(view) ? 'visual' : 'source',
|
||||
})
|
||||
openSearchPanel(view)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const addComment = () => {
|
||||
window.dispatchEvent(new Event('add-new-review-comment'))
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import {
|
||||
ChangeSpec,
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
SelectionRange,
|
||||
} from '@codemirror/state'
|
||||
import {
|
||||
getIndentUnit,
|
||||
IndentContext,
|
||||
indentString,
|
||||
syntaxTree,
|
||||
} from '@codemirror/language'
|
||||
import {
|
||||
ancestorNodeOfType,
|
||||
ancestorOfNodeWithType,
|
||||
ancestorWithType,
|
||||
descendantsOfNodeWithType,
|
||||
wrappedNodeOfType,
|
||||
} from '../../utils/tree-operations/ancestors'
|
||||
import { getEnvironmentName } from '../../utils/tree-operations/environments'
|
||||
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
export const ancestorListType = (state: EditorState): string | null => {
|
||||
const ancestorNode = ancestorWithType(state, ListEnvironment)
|
||||
if (!ancestorNode) {
|
||||
return null
|
||||
}
|
||||
return getEnvironmentName(ancestorNode, state)
|
||||
}
|
||||
|
||||
const wrapRangeInList = (
|
||||
state: EditorState,
|
||||
range: SelectionRange,
|
||||
environment: string,
|
||||
prefix = ''
|
||||
) => {
|
||||
const cx = new IndentContext(state)
|
||||
const columns = cx.lineIndent(range.from)
|
||||
const unit = getIndentUnit(state)
|
||||
const indent = indentString(state, columns)
|
||||
const itemIndent = indentString(state, columns + unit)
|
||||
|
||||
const fromLine = state.doc.lineAt(range.from)
|
||||
const toLine = state.doc.lineAt(range.to)
|
||||
|
||||
// TODO: merge with existing list at the same level?
|
||||
const lines: string[] = [`${indent}\\begin{${environment}}`]
|
||||
for (const line of state.doc.iterLines(fromLine.number, toLine.number + 1)) {
|
||||
let content = line.trim()
|
||||
if (content.endsWith('\\item')) {
|
||||
content += ' ' // ensure a space after \item
|
||||
}
|
||||
|
||||
lines.push(`${itemIndent}${prefix}${content}`)
|
||||
}
|
||||
if (lines.length === 1) {
|
||||
lines.push(`${itemIndent}${prefix}`)
|
||||
}
|
||||
|
||||
const changes = [
|
||||
{
|
||||
from: fromLine.from,
|
||||
to: toLine.to,
|
||||
insert: lines.join('\n'),
|
||||
},
|
||||
]
|
||||
|
||||
// map through the prefix
|
||||
range = EditorSelection.cursor(range.to, -1).map(state.changes(changes), 1)
|
||||
|
||||
changes.push({
|
||||
from: toLine.to,
|
||||
to: toLine.to,
|
||||
insert: `\n${indent}\\end{${environment}}`,
|
||||
})
|
||||
|
||||
return {
|
||||
range,
|
||||
changes,
|
||||
}
|
||||
}
|
||||
|
||||
const wrapRangesInList =
|
||||
(environment: string) =>
|
||||
(view: EditorView): boolean => {
|
||||
view.dispatch(
|
||||
view.state.changeByRange(range =>
|
||||
wrapRangeInList(view.state, range, environment)
|
||||
),
|
||||
{ scrollIntoView: true }
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const unwrapRangeFromList = (
|
||||
state: EditorState,
|
||||
range: SelectionRange,
|
||||
environment: string
|
||||
) => {
|
||||
const node = syntaxTree(state).resolveInner(range.from)
|
||||
const list = ancestorOfNodeWithType(node, ListEnvironment)
|
||||
if (!list) {
|
||||
return { range }
|
||||
}
|
||||
|
||||
const fromLine = state.doc.lineAt(range.from)
|
||||
const toLine = state.doc.lineAt(range.to)
|
||||
|
||||
const listFromLine = state.doc.lineAt(list.from)
|
||||
const listToLine = state.doc.lineAt(list.to)
|
||||
|
||||
const cx = new IndentContext(state)
|
||||
const columns = cx.lineIndent(range.from)
|
||||
const unit = getIndentUnit(state)
|
||||
const indent = indentString(state, columns - unit) // decrease indent depth
|
||||
|
||||
// TODO: only move lines that are list items
|
||||
|
||||
const changes: ChangeSpec[] = []
|
||||
|
||||
if (listFromLine.number === fromLine.number - 1) {
|
||||
// remove \begin if there are no items before this one
|
||||
changes.push({
|
||||
from: listFromLine.from,
|
||||
to: listFromLine.to + 1,
|
||||
insert: '',
|
||||
})
|
||||
} else {
|
||||
// finish the previous list for the previous items
|
||||
changes.push({
|
||||
from: fromLine.from,
|
||||
insert: `${indent}\\end{${environment}}\n`,
|
||||
})
|
||||
}
|
||||
|
||||
const ensureSpace = (state: EditorState, from: number, to: number) => {
|
||||
return /^\s*$/.test(state.doc.sliceString(from, to))
|
||||
}
|
||||
|
||||
for (
|
||||
let lineNumber = fromLine.number;
|
||||
lineNumber <= toLine.number;
|
||||
lineNumber++
|
||||
) {
|
||||
const line = state.doc.line(lineNumber)
|
||||
const to = line.from + unit
|
||||
|
||||
if (to <= line.to && ensureSpace(state, line.from, to)) {
|
||||
// remove indent
|
||||
changes.push({
|
||||
from: line.from,
|
||||
to,
|
||||
insert: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (listToLine.number === toLine.number + 1) {
|
||||
// remove \end if there are no items after this one
|
||||
changes.push({
|
||||
from: listToLine.from,
|
||||
to: listToLine.to + 1,
|
||||
insert: '',
|
||||
})
|
||||
} else {
|
||||
// start a new list for the remaining items
|
||||
changes.push({
|
||||
from: toLine.to,
|
||||
insert: `\n${indent}\\begin{${environment}}`,
|
||||
})
|
||||
}
|
||||
|
||||
// map the range through these changes
|
||||
range = range.map(state.changes(changes), -1)
|
||||
|
||||
return { range, changes }
|
||||
}
|
||||
|
||||
const unwrapRangesFromList =
|
||||
(environment: string) =>
|
||||
(view: EditorView): boolean => {
|
||||
view.dispatch(
|
||||
view.state.changeByRange(range =>
|
||||
unwrapRangeFromList(view.state, range, environment)
|
||||
),
|
||||
{ scrollIntoView: true }
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const toggleListForRange = (
|
||||
view: EditorView,
|
||||
range: SelectionRange,
|
||||
environment: string
|
||||
) => {
|
||||
const ancestorNode =
|
||||
ancestorNodeOfType(view.state, range.head, ListEnvironment) ??
|
||||
wrappedNodeOfType(view.state, range, ListEnvironment)
|
||||
|
||||
if (ancestorNode) {
|
||||
const beginEnvNode = ancestorNode.getChild('BeginEnv')
|
||||
const endEnvNode = ancestorNode.getChild('EndEnv')
|
||||
|
||||
if (beginEnvNode && endEnvNode) {
|
||||
const beginEnvNameNode = beginEnvNode
|
||||
?.getChild('EnvNameGroup')
|
||||
?.getChild('ListEnvName')
|
||||
|
||||
const endEnvNameNode = endEnvNode
|
||||
?.getChild('EnvNameGroup')
|
||||
?.getChild('ListEnvName')
|
||||
|
||||
if (beginEnvNameNode && endEnvNameNode) {
|
||||
const envName = view.state
|
||||
.sliceDoc(beginEnvNameNode.from, beginEnvNameNode.to)
|
||||
.trim()
|
||||
|
||||
if (envName === environment) {
|
||||
const beginLine = view.state.doc.lineAt(beginEnvNode.from)
|
||||
const endLine = view.state.doc.lineAt(endEnvNode.from)
|
||||
|
||||
// whether the command is the only content on this line, apart from whitespace
|
||||
const emptyBeginLine = /^\s*\\begin\{[^}]*}\s*$/.test(beginLine.text)
|
||||
const emptyEndLine = /^\s*\\end\{[^}]*}\s*$/.test(endLine.text)
|
||||
|
||||
// toggle list off
|
||||
const changeSpec: ChangeSpec[] = [
|
||||
{
|
||||
from: emptyBeginLine ? beginLine.from : beginEnvNode.from,
|
||||
to: emptyBeginLine
|
||||
? Math.min(beginLine.to + 1, view.state.doc.length)
|
||||
: beginEnvNode.to,
|
||||
insert: '',
|
||||
},
|
||||
{
|
||||
from: emptyEndLine
|
||||
? Math.max(endLine.from - 1, 0)
|
||||
: endEnvNode.from,
|
||||
to: emptyEndLine ? endLine.to : endEnvNode.to,
|
||||
insert: '',
|
||||
},
|
||||
]
|
||||
|
||||
// items that aren't within nested list environments
|
||||
const itemNodes = descendantsOfNodeWithType(
|
||||
ancestorNode,
|
||||
'Item',
|
||||
ListEnvironment
|
||||
)
|
||||
|
||||
if (itemNodes.length > 0) {
|
||||
const indentUnit = getIndentUnit(view.state)
|
||||
|
||||
for (const itemNode of itemNodes) {
|
||||
const change: ChangeSpec = {
|
||||
from: itemNode.from,
|
||||
to: itemNode.to,
|
||||
insert: '',
|
||||
}
|
||||
|
||||
const line = view.state.doc.lineAt(itemNode.from)
|
||||
|
||||
const lineBeforeCommand = view.state.sliceDoc(
|
||||
line.from,
|
||||
itemNode.from
|
||||
)
|
||||
|
||||
// if the line before the command is empty, remove one unit of indentation
|
||||
if (lineBeforeCommand.trim().length === 0) {
|
||||
const cx = new IndentContext(view.state)
|
||||
const indentation = cx.lineIndent(itemNode.from)
|
||||
change.from -= Math.min(indentation ?? 0, indentUnit)
|
||||
}
|
||||
|
||||
changeSpec.push(change)
|
||||
}
|
||||
}
|
||||
|
||||
const changes = view.state.changes(changeSpec)
|
||||
|
||||
return {
|
||||
range: range.map(changes),
|
||||
changes,
|
||||
}
|
||||
} else {
|
||||
// change list type
|
||||
const changeSpec: ChangeSpec[] = [
|
||||
{
|
||||
from: beginEnvNameNode.from,
|
||||
to: beginEnvNameNode.to,
|
||||
insert: environment,
|
||||
},
|
||||
{
|
||||
from: endEnvNameNode.from,
|
||||
to: endEnvNameNode.to,
|
||||
insert: environment,
|
||||
},
|
||||
]
|
||||
|
||||
const changes = view.state.changes(changeSpec)
|
||||
|
||||
return {
|
||||
range: range.map(changes),
|
||||
changes,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// create a new list
|
||||
return wrapRangeInList(view.state, range, environment, '\\item ')
|
||||
}
|
||||
|
||||
return { range }
|
||||
}
|
||||
|
||||
export const getListItems = (node: SyntaxNode): SyntaxNode[] => {
|
||||
const items: SyntaxNode[] = []
|
||||
|
||||
node.cursor().iterate(nodeRef => {
|
||||
if (nodeRef.type.is('Item')) {
|
||||
items.push(nodeRef.node)
|
||||
}
|
||||
if (nodeRef.type.is('ListEnvironment') && nodeRef.node !== node) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export const toggleListForRanges =
|
||||
(environment: string) => (view: EditorView) => {
|
||||
view.dispatch(
|
||||
view.state.changeByRange(range =>
|
||||
toggleListForRange(view, range, environment)
|
||||
),
|
||||
{ scrollIntoView: true }
|
||||
)
|
||||
}
|
||||
|
||||
export const wrapInBulletList = wrapRangesInList('itemize')
|
||||
export const wrapInNumberedList = wrapRangesInList('enumerate')
|
||||
export const wrapInDescriptionList = wrapRangesInList('description')
|
||||
export const unwrapBulletList = unwrapRangesFromList('itemize')
|
||||
export const unwrapNumberedList = unwrapRangesFromList('enumerate')
|
||||
export const unwrapDescriptionList = unwrapRangesFromList('description')
|
||||
@@ -0,0 +1,165 @@
|
||||
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { ancestorOfNodeWithType } from '../../utils/tree-operations/ancestors'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
export const findCurrentSectionHeadingLevel = (state: EditorState) => {
|
||||
const selections = state.selection.ranges.map(range =>
|
||||
rangeInfo(state, range)
|
||||
)
|
||||
const currentLevels = new Set(selections.map(item => item.level))
|
||||
|
||||
return currentLevels.size === 1 ? selections[0] : null
|
||||
}
|
||||
|
||||
type RangeInfo = {
|
||||
range: SelectionRange
|
||||
command?: SyntaxNode
|
||||
ctrlSeq?: SyntaxNode
|
||||
level: string
|
||||
}
|
||||
|
||||
export const rangeInfo = (
|
||||
state: EditorState,
|
||||
range: SelectionRange
|
||||
): RangeInfo => {
|
||||
const tree = syntaxTree(state)
|
||||
|
||||
const fromNode = tree.resolveInner(range.from, 1)
|
||||
const fromAncestor = ancestorOfNodeWithType(fromNode, 'SectioningCommand')
|
||||
|
||||
const toNode = tree.resolveInner(range.to, -1)
|
||||
const toAncestor = ancestorOfNodeWithType(toNode, 'SectioningCommand')
|
||||
|
||||
const command = fromAncestor ?? toAncestor
|
||||
|
||||
// from and to are both outside section heading
|
||||
if (!command) {
|
||||
return { range, level: 'text' }
|
||||
}
|
||||
|
||||
if (fromAncestor && toAncestor) {
|
||||
// from and to are inside different section headings
|
||||
if (fromAncestor !== toAncestor) {
|
||||
return { range, level: 'text' }
|
||||
}
|
||||
} else {
|
||||
// the range isn't empty and only one end is inside a section heading
|
||||
if (!range.empty) {
|
||||
return { range, level: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
const ctrlSeq = command.firstChild
|
||||
if (!ctrlSeq) {
|
||||
return { range, level: 'text' }
|
||||
}
|
||||
|
||||
const level = state.sliceDoc(ctrlSeq.from + 1, ctrlSeq.to).trim()
|
||||
|
||||
return { command, ctrlSeq, level, range }
|
||||
}
|
||||
|
||||
export const setSectionHeadingLevel = (view: EditorView, level: string) => {
|
||||
view.dispatch(
|
||||
view.state.changeByRange(range => {
|
||||
const info = rangeInfo(view.state, range)
|
||||
|
||||
if (level === info.level) {
|
||||
return { range }
|
||||
}
|
||||
|
||||
if (level === 'text' && info.command) {
|
||||
// remove
|
||||
const argument = info.command.getChild('SectioningArgument')
|
||||
if (argument) {
|
||||
const content = view.state.sliceDoc(
|
||||
argument.from + 1,
|
||||
argument.to - 1
|
||||
)
|
||||
// map through the prefix only
|
||||
const changedRange = range.map(
|
||||
view.state.changes([
|
||||
{ from: info.command.from, to: argument.from + 1, insert: '' },
|
||||
]),
|
||||
1
|
||||
)
|
||||
return {
|
||||
range: changedRange,
|
||||
changes: [
|
||||
{
|
||||
from: info.command.from,
|
||||
to: info.command.to,
|
||||
insert: content,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { range }
|
||||
} else if (info.level === 'text') {
|
||||
// add
|
||||
const insert = {
|
||||
prefix: `\\${level}{`,
|
||||
suffix: '}',
|
||||
}
|
||||
|
||||
const originalRange = range
|
||||
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)
|
||||
} else {
|
||||
if (range.from !== line.from) {
|
||||
insert.prefix = '\n' + insert.prefix
|
||||
}
|
||||
|
||||
if (range.to !== line.to) {
|
||||
insert.suffix += '\n'
|
||||
}
|
||||
}
|
||||
|
||||
const content = view.state.sliceDoc(range.from, range.to)
|
||||
|
||||
// map through the prefix only
|
||||
const changedRange = originalRange.map(
|
||||
view.state.changes([
|
||||
{ from: range.from, insert: `${insert.prefix}` },
|
||||
]),
|
||||
1
|
||||
)
|
||||
|
||||
return {
|
||||
range: changedRange,
|
||||
// create a single change, including the content
|
||||
changes: [
|
||||
{
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
insert: `${insert.prefix}${content}${insert.suffix}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} else {
|
||||
// change
|
||||
if (!info.ctrlSeq) {
|
||||
return { range }
|
||||
}
|
||||
|
||||
const changes = view.state.changes([
|
||||
{
|
||||
from: info.ctrlSeq.from + 1,
|
||||
to: info.ctrlSeq.to,
|
||||
insert: level,
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
range: range.map(changes),
|
||||
changes,
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ scrollIntoView: true }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { environments } from '../../languages/latex/completions/data/environments'
|
||||
import { prepareSnippetTemplate } from '../../languages/latex/snippets'
|
||||
|
||||
export const snippets = {
|
||||
figure: prepareSnippetTemplate(environments.get('figure') as string),
|
||||
table: prepareSnippetTemplate(environments.get('table') as string),
|
||||
cite: prepareSnippetTemplate('\\cite{${}}'),
|
||||
ref: prepareSnippetTemplate('\\ref{${}}'),
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { StateEffect, StateField } from '@codemirror/state'
|
||||
import { EditorView, showPanel } from '@codemirror/view'
|
||||
|
||||
const toggleToolbarEffect = StateEffect.define<boolean>()
|
||||
const toolbarState = StateField.define<boolean>({
|
||||
create: () => true,
|
||||
update: (value, tr) => {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(toggleToolbarEffect)) {
|
||||
value = effect.value
|
||||
}
|
||||
}
|
||||
return value
|
||||
},
|
||||
provide: f => showPanel.from(f, on => (on ? createToolbarPanel : null)),
|
||||
})
|
||||
|
||||
export function createToolbarPanel() {
|
||||
const dom = document.createElement('div')
|
||||
dom.classList.add('ol-cm-toolbar-portal')
|
||||
dom.id = 'ol-cm-toolbar-portal'
|
||||
return { dom, top: true }
|
||||
}
|
||||
|
||||
const toolbarTheme = EditorView.theme({
|
||||
'.ol-cm-toolbar-wrapper': {
|
||||
backgroundColor: 'var(--editor-toolbar-bg)',
|
||||
color: 'var(--toolbar-btn-color)',
|
||||
},
|
||||
'.ol-cm-toolbar': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
'&.overall-theme-dark .ol-cm-toolbar': {
|
||||
'& img': {
|
||||
filter: 'invert(1)',
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-overflow': {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
'#popover-toolbar-overflow': {
|
||||
padding: 0,
|
||||
borderColor: 'rgba(125, 125, 125, 0.2)',
|
||||
backgroundColor: 'var(--editor-toolbar-bg)',
|
||||
color: 'var(--toolbar-btn-color)',
|
||||
'& .popover-content, & .popover-body': {
|
||||
padding: 0,
|
||||
},
|
||||
'& .popover-body': {
|
||||
color: 'inherit',
|
||||
},
|
||||
'& .arrow, & .popover-arrow': {
|
||||
borderBottomColor: 'rgba(125, 125, 125, 0.2)',
|
||||
'&:after': {
|
||||
borderBottomColor: 'var(--editor-toolbar-bg)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-header': {
|
||||
color: 'var(--toolbar-btn-color)',
|
||||
},
|
||||
'.ol-cm-toolbar-dropdown-divider': {
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'var(--toolbar-dropdown-divider-color)',
|
||||
},
|
||||
// here render both the icons, and hide one depending on if its dark or light mode with &.overall-theme-dark
|
||||
'.ol-cm-toolbar-ai-sparkle-gradient': {
|
||||
display: 'block',
|
||||
},
|
||||
'.ol-cm-toolbar-ai-sparkle-white': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.overall-theme-dark .ol-cm-toolbar-ai-sparkle-gradient': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.overall-theme-dark .ol-cm-toolbar-ai-sparkle-white': {
|
||||
display: 'block',
|
||||
},
|
||||
'.ol-cm-toolbar-button-menu-popover': {
|
||||
backgroundColor: 'initial',
|
||||
'& > .popover-content, & > .popover-body': {
|
||||
padding: 0,
|
||||
color: 'initial',
|
||||
},
|
||||
'& .arrow, & .popover-arrow': {
|
||||
display: 'none',
|
||||
},
|
||||
'& .list-group': {
|
||||
marginBottom: 0,
|
||||
backgroundColor: 'var(--editor-toolbar-bg)',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'& .list-group-item': {
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
color: 'var(--toolbar-btn-color)',
|
||||
borderColor: 'var(--editor-toolbar-bg)',
|
||||
background: 'none',
|
||||
'&:hover, &:focus': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-button-group': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
flexWrap: 'nowrap',
|
||||
padding: '0 4px',
|
||||
margin: '4px 0',
|
||||
lineHeight: '1',
|
||||
borderLeft: '1px solid rgba(125, 125, 125, 0.3)',
|
||||
'&.ol-cm-toolbar-end': {
|
||||
borderLeft: 'none',
|
||||
},
|
||||
'&.ol-cm-toolbar-stretch': {
|
||||
flex: 1,
|
||||
'.editor-toggle-switch + &': {
|
||||
borderLeft: 'none', // avoid a left border when no toolbar buttons are shown
|
||||
},
|
||||
},
|
||||
'&.overflow-hidden': {
|
||||
borderLeft: 'none',
|
||||
width: 0,
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-button': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0',
|
||||
margin: '0 1px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
lineHeight: '1',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
overflow: 'hidden',
|
||||
color: 'inherit',
|
||||
'&:hover, &:focus, &:active, &.active': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.1)',
|
||||
color: 'inherit',
|
||||
boxShadow: 'none',
|
||||
'&[aria-disabled="true"]': {
|
||||
opacity: '0.2',
|
||||
},
|
||||
},
|
||||
'&.active, &:active': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.2)',
|
||||
},
|
||||
'&[aria-disabled="true"]': {
|
||||
opacity: '0.2',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
'.overflow-hidden &': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.ol-cm-toolbar-button-math': {
|
||||
fontFamily: '"Noto Serif", serif',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
'&.overall-theme-dark .ol-cm-toolbar-button': {
|
||||
opacity: 0.8,
|
||||
'&:hover, &:focus, &:active, &.active': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.2)',
|
||||
},
|
||||
'&.active, &:active': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.4)',
|
||||
},
|
||||
'&[aria-disabled="true"]': {
|
||||
opacity: 0.2,
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-end': {
|
||||
justifyContent: 'flex-end',
|
||||
'& .badge': {
|
||||
marginRight: '5px',
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-overflow-toggle': {
|
||||
display: 'none',
|
||||
'&.ol-cm-toolbar-overflow-toggle-visible': {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-menu-toggle': {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
opacity: 0.8,
|
||||
width: '120px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '5px 6px',
|
||||
'&:hover, &:focus, &.active': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.1)',
|
||||
opacity: '1',
|
||||
color: 'inherit',
|
||||
},
|
||||
'& .caret': {
|
||||
marginTop: '0',
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-menu-popover': {
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
borderBottomLeftRadius: '4px',
|
||||
borderBottomRightRadius: '4px',
|
||||
boxShadow: '0 2px 5px rgb(0 0 0 / 20%)',
|
||||
backgroundColor: 'var(--editor-toolbar-bg)',
|
||||
color: 'var(--toolbar-btn-color)',
|
||||
padding: '0',
|
||||
'&.bottom': {
|
||||
marginTop: '1px',
|
||||
},
|
||||
'&.top': {
|
||||
marginBottom: '1px',
|
||||
},
|
||||
'& .arrow, & .popover-arrow': {
|
||||
display: 'none',
|
||||
},
|
||||
'& .popover-content, & > .popover-body': {
|
||||
padding: '0',
|
||||
color: 'inherit',
|
||||
},
|
||||
'& .ol-cm-toolbar-menu': {
|
||||
width: '120px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'& .ol-cm-toolbar-menu-item': {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
padding: '4px 12px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: 'inherit',
|
||||
'&.ol-cm-toolbar-menu-item-active': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.1)',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(125, 125, 125, 0.2)',
|
||||
color: 'inherit',
|
||||
},
|
||||
'&.section-level-section': {
|
||||
fontSize: '1.44em',
|
||||
},
|
||||
'&.section-level-subsection': {
|
||||
fontSize: '1.2em',
|
||||
},
|
||||
'&.section-level-body': {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
},
|
||||
},
|
||||
'&.overall-theme-dark .ol-cm-toolbar-table-grid': {
|
||||
'& td.active': {
|
||||
outlineColor: 'white',
|
||||
background: 'rgb(125, 125, 125)',
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-table-grid': {
|
||||
borderCollapse: 'separate',
|
||||
tableLayout: 'fixed',
|
||||
fontSize: '6px',
|
||||
cursor: 'pointer',
|
||||
width: '160px',
|
||||
'& td': {
|
||||
outline: '1px solid #E7E9EE',
|
||||
outlineOffset: '-2px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
|
||||
'&.active': {
|
||||
outlineColor: '#3265B2',
|
||||
background: '#F1F4F9',
|
||||
},
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-table-size-label': {
|
||||
maxWidth: '160px',
|
||||
fontFamily: 'Lato, sans-serif',
|
||||
fontSize: '12px',
|
||||
},
|
||||
'.ol-cm-toolbar-table-grid-popover': {
|
||||
maxWidth: 'unset',
|
||||
padding: '8px',
|
||||
boxShadow: '0 5px 10px rgba(0, 0, 0, 0.2)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--editor-toolbar-bg)',
|
||||
pointerEvents: 'all',
|
||||
color: 'var(--toolbar-btn-color)',
|
||||
},
|
||||
'.ol-cm-toolbar-button-menu-popover-unstyled': {
|
||||
maxWidth: 'unset',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
padding: '0 8px 8px 160px',
|
||||
boxShadow: 'none',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* A panel which contains the editor toolbar, provided by a state field which allows the toolbar to be toggled,
|
||||
* and styles for the toolbar.
|
||||
*/
|
||||
export const toolbarPanel = () => [toolbarState, toolbarTheme]
|
||||
@@ -0,0 +1,25 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { sendMB } from '../../../../../infrastructure/event-tracking'
|
||||
import { isVisual } from '../../visual/visual'
|
||||
|
||||
export function emitCommandEvent(
|
||||
view: EditorView,
|
||||
key: string,
|
||||
command: string,
|
||||
segmentation?: Record<string, string | number | boolean>
|
||||
) {
|
||||
const mode = isVisual(view) ? 'visual' : 'source'
|
||||
sendMB(key, { command, mode, ...segmentation })
|
||||
}
|
||||
|
||||
export function emitToolbarEvent(view: EditorView, command: string) {
|
||||
emitCommandEvent(view, 'codemirror-toolbar-event', command)
|
||||
}
|
||||
|
||||
export function emitShortcutEvent(
|
||||
view: EditorView,
|
||||
command: string,
|
||||
segmentation?: Record<string, string | number | boolean>
|
||||
) {
|
||||
emitCommandEvent(view, 'codemirror-shortcut-event', command, segmentation)
|
||||
}
|
||||
Reference in New Issue
Block a user