first commit
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user