first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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