Upgrade to CodeMirror 6 (#1787)

Upgrade to CodeMirror 6

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-02-13 12:14:01 +01:00 committed by GitHub
parent 1a09bfa5f1
commit 6a6f6105b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 1906 additions and 2615 deletions

View file

@ -1,116 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import type { Hinter } from './index'
import { findWordAtCursor, generateHintListByPrefix } from './index'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { Logger } from '../../../../utils/logger'
type highlightJsImport = typeof import('../../../common/hljs/hljs')
const log = new Logger('Autocompletion > CodeBlock')
const wordRegExp = /^```((?:\w|-|\+)*)$/
let allSupportedLanguages: string[] = []
/**
* Fetches the highlight js chunk.
* @return the retrieved highlight js api
*/
const loadHighlightJs = async (): Promise<highlightJsImport | null> => {
try {
return await import('../../../common/hljs/hljs')
} catch (error) {
showErrorNotification('common.errorWhileLoadingLibrary', { name: 'highlight.js' })(error as Error)
log.error('Error while loading highlight.js', error)
return null
}
}
/**
* Extracts the language from the current line in the editor.
*
* @param editor The editor that contains the search time
* @return null if no search term could be found or the found word and the cursor position.
*/
const extractSearchTerm = (
editor: Editor
): null | {
searchTerm: string
startIndex: number
endIndex: number
} => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
return null
}
return {
searchTerm: searchResult[1],
startIndex: searchTerm.start,
endIndex: searchTerm.end
}
}
/**
* Builds the list of languages that are supported by highlight js or custom embeddings.
* @return An array of language names
*/
const buildLanguageList = async (): Promise<string[]> => {
const highlightJs = await loadHighlightJs()
if (highlightJs === null) {
return []
}
if (allSupportedLanguages.length === 0) {
allSupportedLanguages = highlightJs.default
.listLanguages()
.concat('csv', 'flow', 'html', 'js', 'markmap', 'abc', 'graphviz', 'mermaid', 'vega-lite')
}
return allSupportedLanguages
}
/**
* Creates a codemirror autocompletion hint with supported highlight js languages.
*
* @param editor The codemirror editor that requested the autocompletion
* @return The generated {@link Hints} or null if no hints exist.
*/
const codeBlockHint = async (editor: Editor): Promise<Hints | null> => {
const searchResult = extractSearchTerm(editor)
if (!searchResult) {
return null
}
const languages = await buildLanguageList()
if (languages.length === 0) {
return null
}
const suggestions = generateHintListByPrefix(searchResult.searchTerm, languages)
if (!suggestions) {
return null
}
const lineIndex = editor.getCursor().line
return {
list: suggestions.map(
(suggestion: string): Hint => ({
text: '```' + suggestion + '\n\n```\n',
displayText: suggestion
})
),
from: Pos(lineIndex, searchResult.startIndex),
to: Pos(lineIndex, searchResult.endIndex)
}
}
export const CodeBlockHinter: Hinter = {
wordRegExp,
hint: codeBlockHint
}

View file

@ -1,43 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import type { Hinter } from './index'
import { findWordAtCursor } from './index'
const wordRegExp = /^(<d(?:e|et|eta|etai|etail|etails)?)$/
const collapsibleBlockHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = ['<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>']
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map(
(suggestion: string): Hint => ({
text: suggestion
})
),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const CollapsibleBlockHinter: Hinter = {
wordRegExp,
hint: collapsibleBlockHint
}

View file

@ -1,51 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import type { Hinter } from './index'
import { findWordAtCursor } from './index'
import { alertLevels } from '../../../markdown-renderer/markdown-extension/alert-markdown-extension'
const wordRegExp = /^:::((?:\w|-|\+)*)$/
const spoilerSuggestion: Hint = {
text: ':::spoiler Toggle label\nToggled content\n::: \n',
displayText: 'spoiler'
}
const suggestions = alertLevels
.map(
(suggestion: string): Hint => ({
text: ':::' + suggestion + '\n\n::: \n',
displayText: suggestion
})
)
.concat(spoilerSuggestion)
const containerHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.filter((suggestion) => suggestion.displayText?.startsWith(searchResult[1])),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end)
})
}
})
}
export const ContainerHinter: Hinter = {
wordRegExp,
hint: containerHint
}

View file

@ -1,82 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import Database from 'emoji-picker-element/database'
import type { Emoji, EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared'
import { emojiPickerConfig } from '../tool-bar/emoji-picker/emoji-picker'
import { getEmojiIcon, getEmojiShortCode } from '../tool-bar/utils/emojiUtils'
import type { Hinter } from './index'
import { findWordAtCursor } from './index'
import { Logger } from '../../../../utils/logger'
const emojiIndex = new Database(emojiPickerConfig)
const emojiWordRegex = /^:([\w-_+]*)$/
const log = new Logger('Autocompletion > Emoji')
const findEmojiInDatabase = async (emojiIndex: Database, term: string): Promise<Emoji[]> => {
try {
if (term === '') {
return await emojiIndex.getTopFavoriteEmoji(7)
}
const queryResult = await emojiIndex.getEmojiBySearchQuery(term)
if (queryResult.length === 0) {
return await emojiIndex.getTopFavoriteEmoji(7)
} else {
return queryResult
}
} catch (error) {
log.error('Error while searching for emoji', term, error)
return []
}
}
const convertEmojiEventToHint = (emojiData: EmojiClickEventDetail): Hint | undefined => {
const shortCode = getEmojiShortCode(emojiData)
if (!shortCode) {
return undefined
}
return {
text: shortCode,
render: (parent: HTMLLIElement) => {
const wrapper = document.createElement('div')
wrapper.innerHTML = `${getEmojiIcon(emojiData)} ${shortCode}`
parent.appendChild(wrapper)
}
}
}
const generateEmojiHints = async (editor: Editor): Promise<Hints | null> => {
const searchTerm = findWordAtCursor(editor)
const searchResult = emojiWordRegex.exec(searchTerm.text)
if (searchResult === null) {
return null
}
const suggestionList: Emoji[] = await findEmojiInDatabase(emojiIndex, searchResult[1])
const cursor = editor.getCursor()
const skinTone = await emojiIndex.getPreferredSkinTone()
const emojiEventDetails: EmojiClickEventDetail[] = suggestionList
.filter((emoji) => !!emoji.shortcodes)
.map((emoji) => ({
emoji,
skinTone: skinTone,
unicode: (emoji as NativeEmoji).unicode ? (emoji as NativeEmoji).unicode : undefined,
name: emoji.name
}))
const hints = emojiEventDetails.map(convertEmojiEventToHint).filter((o) => !!o) as Hint[]
return {
list: hints,
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end)
}
}
export const EmojiHinter: Hinter = {
wordRegExp: emojiWordRegex,
hint: generateEmojiHints
}

View file

@ -1,51 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import type { Hinter } from './index'
import { findWordAtCursor, generateHintListByPrefix } from './index'
const wordRegExp = /^(\s{0,3})(#{1,6})$/
const allSupportedHeaders = ['# h1', '## h2', '### h3', '#### h4', '##### h5', '###### h6', '###### tags: `example`']
const allSupportedHeadersTextToInsert = ['# ', '## ', '### ', '#### ', '##### ', '###### ', '###### tags: `example`']
const headerHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const term = searchResult[0]
if (!term) {
resolve(null)
return
}
const suggestions = generateHintListByPrefix(term, allSupportedHeaders)
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map(
(suggestion): Hint => ({
text: allSupportedHeadersTextToInsert[allSupportedHeaders.indexOf(suggestion)],
displayText: suggestion
})
),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end)
})
}
})
}
export const HeaderHinter: Hinter = {
wordRegExp,
hint: headerHint
}

View file

@ -1,48 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import type { Hinter } from './index'
import { findWordAtCursor } from './index'
const wordRegExp = /^(!(\[.*])?)$/
const allSupportedImages = [
'![image alt](https:// "title")',
'![image alt](https:// "title" =WidthxHeight)',
'![image alt][reference]'
]
const imageHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = allSupportedImages
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map(
(suggestion: string): Hint => ({
text: suggestion
})
),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const ImageHinter: Hinter = {
wordRegExp,
hint: imageHint
}

View file

@ -1,69 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hints } from 'codemirror'
import { CodeBlockHinter } from './code-block'
import { CollapsibleBlockHinter } from './collapsible-block'
import { ContainerHinter } from './container'
import { EmojiHinter } from './emoji'
import { HeaderHinter } from './header'
import { ImageHinter } from './image'
import { LinkAndExtraTagHinter } from './link-and-extra-tag'
import { PDFHinter } from './pdf'
interface findWordAtCursorResponse {
start: number
end: number
text: string
}
export interface Hinter {
wordRegExp: RegExp
hint: (editor: Editor) => Promise<Hints | null>
}
const allowedChars = /[^\s]/
export const findWordAtCursor = (editor: Editor): findWordAtCursorResponse => {
const cursor = editor.getCursor()
const line = editor.getLine(cursor.line)
let start = cursor.ch
let end = cursor.ch
while (start && allowedChars.test(line.charAt(start - 1))) {
--start
}
while (end < line.length && allowedChars.test(line.charAt(end))) {
++end
}
return {
text: line.slice(start, end).toLowerCase(),
start: start,
end: end
}
}
/**
* Generates a list (with max 8 entries) of hints for the autocompletion.
*
* @param prefix This is the case insensitive prefix that every hint must have
* @param hintCandidates The list of hint candidates
*/
export const generateHintListByPrefix = (prefix: string, hintCandidates: string[]): string[] => {
const searchTerm = prefix.toLowerCase()
return hintCandidates.filter((item) => item.toLowerCase().startsWith(searchTerm)).slice(0, 7)
}
export const allHinters: Hinter[] = [
CodeBlockHinter,
ContainerHinter,
EmojiHinter,
HeaderHinter,
ImageHinter,
LinkAndExtraTagHinter,
PDFHinter,
CollapsibleBlockHinter
]

View file

@ -1,77 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import { DateTime } from 'luxon'
import type { Hinter } from './index'
import { findWordAtCursor } from './index'
import { getGlobalState } from '../../../../redux'
const wordRegExp = /^(\[(.*])?)$/
const allSupportedLinks = [
'[link text](https:// "title")',
'[reference]: https:// "title"',
'[link text][reference]',
'[reference]',
'[^footnote reference]: https://',
'[^footnote reference]',
'^[inline footnote]',
'[TOC]',
'name',
'time',
'[color=#FFFFFF]'
]
const getUserName = (): string => {
const user = getGlobalState().user
return user ? user.displayName : 'Anonymous'
}
const linkAndExtraTagHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = allSupportedLinks
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion: string): Hint => {
switch (suggestion) {
case 'name':
// Get the user when a completion happens, this prevents to early calls resulting in 'Anonymous'
return {
text: `[name=${getUserName()}]`
}
case 'time':
// show the current time when the autocompletion is opened and not when the function is loaded
return {
text: `[time=${DateTime.local().toFormat('DDDD T')}]`
}
default:
return {
text: suggestion + ' ',
displayText: suggestion
}
}
}),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const LinkAndExtraTagHinter: Hinter = {
wordRegExp,
hint: linkAndExtraTagHint
}

View file

@ -1,43 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror'
import type { Hinter } from './index'
import { findWordAtCursor } from './index'
const wordRegExp = /^({[%}]?)$/
const pdfHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = ['{%pdf https:// %}']
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map(
(suggestion: string): Hint => ({
text: suggestion
})
),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const PDFHinter: Hinter = {
wordRegExp,
hint: pdfHint
}

View file

@ -1,89 +1,112 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, EditorChange } from 'codemirror'
import React, { useCallback, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import type { ScrollProps } from '../synced-scroll/scroll-props'
import { StatusBar } from './status-bar/status-bar'
import { ToolBar } from './tool-bar/tool-bar'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { setNoteContent } from '../../../redux/note-details/methods'
import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content'
import { useCodeMirrorOptions } from './hooks/use-code-mirror-options'
import { useOnEditorPasteCallback } from './hooks/use-on-editor-paste-callback'
import { useOnEditorFileDrop } from './hooks/use-on-editor-file-drop'
import { useOnEditorScroll } from './hooks/use-on-editor-scroll'
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer'
import { ExtendedCodemirror } from './extended-codemirror/extended-codemirror'
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror'
import ReactCodeMirror from '@uiw/react-codemirror'
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
import styles from './extended-codemirror/codemirror.module.scss'
import { oneDark } from '@codemirror/theme-one-dark'
import { useTranslation } from 'react-i18next'
import { Logger } from '../../../utils/logger'
import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension'
import { useCodeMirrorPasteExtension } from './hooks/code-mirror-extensions/use-code-mirror-paste-extension'
import { useCodeMirrorFileDropExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-drop-extension'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { languages } from '@codemirror/language-data'
import { EditorView } from '@codemirror/view'
import { autocompletion } from '@codemirror/autocomplete'
import { useCodeMirrorFocusReference } from './hooks/use-code-mirror-focus-reference'
import { useOffScreenScrollProtection } from './hooks/use-off-screen-scroll-protection'
const logger = new Logger('EditorPane')
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const markdownContent = useNoteMarkdownContent()
const editor = useRef<Editor>()
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const codeMirrorRef = useRef<ReactCodeMirrorRef | null>(null)
const onPaste = useOnEditorPasteCallback()
const onEditorScroll = useOnEditorScroll(onScroll)
useApplyScrollState(editor, scrollState)
useApplyScrollState(codeMirrorRef, scrollState)
const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => {
setNoteContent(value)
}, [])
const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
const editorPasteExtension = useCodeMirrorPasteExtension()
const dropExtension = useCodeMirrorFileDropExtension()
const [focusExtension, editorFocused] = useCodeMirrorFocusReference()
const saveOffFocusScrollStateExtensions = useOffScreenScrollProtection()
const cursorActivityExtension = useCursorActivityCallback(editorFocused)
const onBeforeChange = useCallback(
(value: string): void => {
if (!editorFocused.current) {
logger.debug("Don't post content change because editor isn't focused")
} else {
setNoteContent(value)
}
},
[editorFocused]
)
const extensions = useMemo(
() => [
markdown({ base: markdownLanguage, codeLanguages: languages }),
...saveOffFocusScrollStateExtensions,
focusExtension,
EditorView.lineWrapping,
editorScrollExtension,
editorPasteExtension,
dropExtension,
autocompletion(),
cursorActivityExtension
],
[
cursorActivityExtension,
dropExtension,
editorPasteExtension,
editorScrollExtension,
focusExtension,
saveOffFocusScrollStateExtensions
]
)
useOnImageUploadFromRenderer()
const onEditorDidMount = useCallback((mountedEditor: Editor) => {
editor.current = mountedEditor
}, [])
const onCursorActivity = useCursorActivityCallback()
const onDrop = useOnEditorFileDrop()
const codeMirrorOptions = useCodeMirrorOptions()
const editorFocus = useRef<boolean>(false)
const onFocus = useCallback(() => {
editorFocus.current = true
if (editor.current) {
onCursorActivity(editor.current)
}
}, [editor, onCursorActivity])
const onBlur = useCallback(() => {
editorFocus.current = false
}, [])
const cursorActivity = useCallback(
(editor: Editor) => {
if (editorFocus.current) {
onCursorActivity(editor)
}
},
[onCursorActivity]
const codeMirrorClassName = useMemo(
() => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`,
[ligaturesEnabled]
)
const { t } = useTranslation()
return (
<div className={`d-flex flex-column h-100 position-relative`} onMouseEnter={onMakeScrollSource}>
<MaxLengthWarning />
<ToolBar />
<ExtendedCodemirror
className={`overflow-hidden w-100 flex-fill`}
<ReactCodeMirror
placeholder={t('editor.placeholder')}
extensions={extensions}
width={'100%'}
height={'100%'}
maxHeight={'100%'}
maxWidth={'100%'}
basicSetup={true}
className={codeMirrorClassName}
theme={oneDark}
value={markdownContent}
options={codeMirrorOptions}
onPaste={onPaste}
onDrop={onDrop}
onCursorActivity={cursorActivity}
editorDidMount={onEditorDidMount}
onBeforeChange={onBeforeChange}
onScroll={onEditorScroll}
onFocus={onFocus}
onBlur={onBlur}
ligatures={ligaturesEnabled}
onChange={onBeforeChange}
ref={codeMirrorRef}
/>
<StatusBar />
</div>

View file

@ -1,150 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
// codemirror addons
import 'codemirror/addon/comment/comment'
import 'codemirror/addon/dialog/dialog'
import 'codemirror/addon/display/autorefresh'
import 'codemirror/addon/display/fullscreen'
import 'codemirror/addon/display/placeholder'
import 'codemirror/addon/edit/closebrackets'
import 'codemirror/addon/edit/closetag'
import 'codemirror/addon/edit/continuelist'
import 'codemirror/addon/edit/matchbrackets'
import 'codemirror/addon/edit/matchtags'
import 'codemirror/addon/fold/foldcode'
import 'codemirror/addon/fold/foldgutter'
import 'codemirror/addon/fold/markdown-fold'
import 'codemirror/addon/hint/show-hint'
import 'codemirror/addon/search/jump-to-line'
import 'codemirror/addon/search/match-highlighter'
import 'codemirror/addon/search/search'
import 'codemirror/addon/selection/active-line'
// codemirror keymaps
import 'codemirror/keymap/emacs'
import 'codemirror/keymap/sublime'
import 'codemirror/keymap/vim'
// codemirror syntax highlighting modes
import 'codemirror/mode/apl/apl'
import 'codemirror/mode/asciiarmor/asciiarmor'
import 'codemirror/mode/asn.1/asn.1'
import 'codemirror/mode/asterisk/asterisk'
import 'codemirror/mode/brainfuck/brainfuck'
import 'codemirror/mode/clike/clike'
import 'codemirror/mode/clojure/clojure'
import 'codemirror/mode/cmake/cmake'
import 'codemirror/mode/cobol/cobol'
import 'codemirror/mode/coffeescript/coffeescript'
import 'codemirror/mode/commonlisp/commonlisp'
import 'codemirror/mode/crystal/crystal'
import 'codemirror/mode/css/css'
import 'codemirror/mode/cypher/cypher'
import 'codemirror/mode/d/d'
import 'codemirror/mode/dart/dart'
import 'codemirror/mode/diff/diff'
import 'codemirror/mode/django/django'
import 'codemirror/mode/dockerfile/dockerfile'
import 'codemirror/mode/dtd/dtd'
import 'codemirror/mode/dylan/dylan'
import 'codemirror/mode/ebnf/ebnf'
import 'codemirror/mode/ecl/ecl'
import 'codemirror/mode/eiffel/eiffel'
import 'codemirror/mode/elm/elm'
import 'codemirror/mode/erlang/erlang'
import 'codemirror/mode/factor/factor'
import 'codemirror/mode/fcl/fcl'
import 'codemirror/mode/forth/forth'
import 'codemirror/mode/fortran/fortran'
import 'codemirror/mode/gas/gas'
import 'codemirror/mode/gfm/gfm'
import 'codemirror/mode/gherkin/gherkin'
import 'codemirror/mode/go/go'
import 'codemirror/mode/groovy/groovy'
import 'codemirror/mode/haml/haml'
import 'codemirror/mode/handlebars/handlebars'
import 'codemirror/mode/haskell/haskell'
import 'codemirror/mode/haskell-literate/haskell-literate'
import 'codemirror/mode/haxe/haxe'
import 'codemirror/mode/htmlembedded/htmlembedded'
import 'codemirror/mode/htmlmixed/htmlmixed'
import 'codemirror/mode/http/http'
import 'codemirror/mode/idl/idl'
import 'codemirror/mode/javascript/javascript'
import 'codemirror/mode/jinja2/jinja2'
import 'codemirror/mode/jsx/jsx'
import 'codemirror/mode/julia/julia'
import 'codemirror/mode/livescript/livescript'
import 'codemirror/mode/lua/lua'
import 'codemirror/mode/markdown/markdown'
import 'codemirror/mode/mathematica/mathematica'
import 'codemirror/mode/mbox/mbox'
import 'codemirror/mode/mirc/mirc'
import 'codemirror/mode/mllike/mllike'
import 'codemirror/mode/modelica/modelica'
import 'codemirror/mode/mscgen/mscgen'
import 'codemirror/mode/mumps/mumps'
import 'codemirror/mode/nginx/nginx'
import 'codemirror/mode/nsis/nsis'
import 'codemirror/mode/ntriples/ntriples'
import 'codemirror/mode/octave/octave'
import 'codemirror/mode/oz/oz'
import 'codemirror/mode/pascal/pascal'
import 'codemirror/mode/pegjs/pegjs'
import 'codemirror/mode/perl/perl'
import 'codemirror/mode/php/php'
import 'codemirror/mode/pig/pig'
import 'codemirror/mode/powershell/powershell'
import 'codemirror/mode/properties/properties'
import 'codemirror/mode/protobuf/protobuf'
import 'codemirror/mode/pug/pug'
import 'codemirror/mode/puppet/puppet'
import 'codemirror/mode/python/python'
import 'codemirror/mode/q/q'
import 'codemirror/mode/r/r'
import 'codemirror/mode/rpm/rpm'
import 'codemirror/mode/rst/rst'
import 'codemirror/mode/ruby/ruby'
import 'codemirror/mode/rust/rust'
import 'codemirror/mode/sas/sas'
import 'codemirror/mode/sass/sass'
import 'codemirror/mode/scheme/scheme'
import 'codemirror/mode/shell/shell'
import 'codemirror/mode/sieve/sieve'
import 'codemirror/mode/slim/slim'
import 'codemirror/mode/smalltalk/smalltalk'
import 'codemirror/mode/smarty/smarty'
import 'codemirror/mode/solr/solr'
import 'codemirror/mode/soy/soy'
import 'codemirror/mode/sparql/sparql'
import 'codemirror/mode/spreadsheet/spreadsheet'
import 'codemirror/mode/sql/sql'
import 'codemirror/mode/stex/stex'
import 'codemirror/mode/stylus/stylus'
import 'codemirror/mode/swift/swift'
import 'codemirror/mode/tcl/tcl'
import 'codemirror/mode/textile/textile'
import 'codemirror/mode/tiddlywiki/tiddlywiki'
import 'codemirror/mode/tiki/tiki'
import 'codemirror/mode/toml/toml'
import 'codemirror/mode/tornado/tornado'
import 'codemirror/mode/troff/troff'
import 'codemirror/mode/ttcn/ttcn'
import 'codemirror/mode/ttcn-cfg/ttcn-cfg'
import 'codemirror/mode/turtle/turtle'
import 'codemirror/mode/twig/twig'
import 'codemirror/mode/vb/vb'
import 'codemirror/mode/vbscript/vbscript'
import 'codemirror/mode/velocity/velocity'
import 'codemirror/mode/verilog/verilog'
import 'codemirror/mode/vhdl/vhdl'
import 'codemirror/mode/vue/vue'
import 'codemirror/mode/wast/wast'
import 'codemirror/mode/webidl/webidl'
import 'codemirror/mode/xml/xml'
import 'codemirror/mode/xquery/xquery'
import 'codemirror/mode/yacas/yacas'
import 'codemirror/mode/yaml/yaml'
import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter'
import 'codemirror/mode/z80/z80'

View file

@ -4,44 +4,15 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
.extended-codemirror {
:global {
@import '~codemirror/lib/codemirror';
@import '~codemirror/addon/display/fullscreen';
@import '~codemirror/addon/fold/foldgutter';
@import '~codemirror/addon/dialog/dialog';
@import '~codemirror/theme/neat';
@import './one-dark';
@import './hints';
.CodeMirror {
& {
@import '../../../../../global-styles/variables.module';
font-family: "Fira Code", $font-family-emojis, Consolas, monaco, monospace;
}
letter-spacing: 0.025em;
line-height: 1.25;
font-size: 18px;
height: 100%;
}
.file-drag .CodeMirror-cursors {
visibility: visible;
}
.extendedCodemirror {
:global(.cm-editor .cm-line) {
@import '../../../../../global-styles/variables.module';
font-family: "Fira Code", $font-family-emojis, Consolas, monaco, monospace;
}
&.no-ligatures {
:global {
.CodeMirror {
.CodeMirror-line, .CodeMirror-line-like {
font-feature-settings: inherit;
}
.CodeMirror-line, .CodeMirror-line-like {
font-variant-ligatures: none;
}
}
:global(.cm-editor .cm-line) {
font-variant-ligatures: none;
}
}
}

View file

@ -1,50 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import type { IControlledCodeMirror } from 'react-codemirror2'
import { Controlled } from 'react-codemirror2'
import './codemirror-imports'
import styles from './codemirror.module.scss'
import { allHinters, findWordAtCursor } from '../autocompletion'
import type { Editor } from 'codemirror'
export interface ExtendedCodemirrorProps extends Omit<IControlledCodeMirror, 'onChange'> {
ligatures?: boolean
}
const onChange = (editor: Editor) => {
const searchTerm = findWordAtCursor(editor)
for (const hinter of allHinters) {
if (hinter.wordRegExp.test(searchTerm.text)) {
editor.showHint({
container: editor.getWrapperElement(),
hint: hinter.hint,
completeSingle: false,
completeOnSingleClick: false,
alignWithWord: true
})
return
}
}
}
/**
* A {@link Controlled controlled code mirror} but with several addons, different font, ligatures and other improvements.
*
* @param className Additional css class names that should be added to the component
* @param ligatures Renders text ligatures if {@code true}
* @param props Other code mirror props that will be forwarded to the editor
*/
export const ExtendedCodemirror: React.FC<ExtendedCodemirrorProps> = ({ className, ligatures, ...props }) => {
return (
<Controlled
className={`${className ?? ''} ${ligatures ? '' : styles['no-ligatures']} ${styles['extended-codemirror']}`}
onChange={onChange}
{...props}
/>
)
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback, useMemo } from 'react'
import { handleUpload } from '../../upload-handler'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
import Optional from 'optional-js'
/**
* Creates a callback that is used to process file drops on the code mirror editor
*
* @return the code mirror callback
*/
export const useCodeMirrorFileDropExtension = (): Extension => {
const onDrop = useCallback((event: DragEvent, view: EditorView): void => {
if (!event.pageX || !event.pageY) {
return
}
Optional.ofNullable(event.dataTransfer?.files)
.filter((files) => files.length > 0)
.ifPresent((files) => {
event.preventDefault()
const newCursor = view.posAtCoords({ y: event.pageY, x: event.pageX })
if (newCursor === null) {
return
}
handleUpload(files[0], { from: newCursor })
})
}, [])
return useMemo(
() =>
EditorView.domEventHandlers({
drop: onDrop
}),
[onDrop]
)
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { handleFilePaste, handleTablePaste } from '../../tool-bar/utils/pasteHandlers'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
/**
* Creates a {@link Extension code mirror extension} that handles the table or file paste action.
*
* @return the created {@link Extension code mirror extension}
*/
export const useCodeMirrorPasteExtension = (): Extension => {
return useMemo(
() =>
EditorView.domEventHandlers({
paste: (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) {
return
}
if (handleTablePaste(clipboardData) || handleFilePaste(clipboardData)) {
event.preventDefault()
return
}
}
}),
[]
)
}

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback, useMemo } from 'react'
import type { ScrollState } from '../../../synced-scroll/scroll-props'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
export type OnScrollCallback = ((scrollState: ScrollState) => void) | undefined
/**
* Extracts the {@link ScrollState scroll state} from the given {@link EditorView editor view}.
*
* @param view The {@link EditorView editor view} whose scroll state should be extracted.
*/
export const extractScrollState = (view: EditorView): ScrollState => {
const state = view.state
const scrollTop = view.scrollDOM.scrollTop
const lineBlockAtHeight = view.lineBlockAtHeight(scrollTop)
const line = state.doc.lineAt(lineBlockAtHeight.from)
const percentageRaw = (scrollTop - lineBlockAtHeight.top) / lineBlockAtHeight.height
const scrolledPercentage = Math.floor(percentageRaw * 100)
return {
firstLineInView: line.number,
scrolledPercentage
}
}
/**
* Creates a code mirror extension for the scroll binding.
* It calculates a {@link ScrollState} and posts it on change.
*
* @param onScroll The callback that is used to post {@link ScrollState scroll states} when the editor view is scrolling.
* @return The extensions that watches the scrolling in the editor.
*/
export const useCodeMirrorScrollWatchExtension = (onScroll: OnScrollCallback): Extension => {
const onEditorScroll = useCallback(
(view: EditorView) => {
if (!onScroll || !view) {
return undefined
}
onScroll(extractScrollState(view))
},
[onScroll]
)
return useMemo(
() =>
EditorView.domEventHandlers({
scroll: (event, view) => onEditorScroll(view)
}),
[onEditorScroll]
)
}

View file

@ -6,8 +6,24 @@
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import type { Editor } from 'codemirror'
import type { ScrollState } from '../../synced-scroll/scroll-props'
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror'
import { EditorView } from '@codemirror/view'
import equal from 'fast-deep-equal'
/**
* Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}.
*
* @param view The {@link EditorView view} that should be scrolled
* @param scrollState The {@link ScrollState scroll state} that should be applied
*/
export const applyScrollState = (view: EditorView, scrollState: ScrollState): void => {
const line = view.state.doc.line(scrollState.firstLineInView)
const lineBlock = view.lineBlockAt(line.from)
const margin = Math.floor(lineBlock.height * scrollState.scrolledPercentage) / 100
const stateEffect = EditorView.scrollIntoView(line.from, { y: 'start', yMargin: -margin })
view.dispatch({ effects: [stateEffect] })
}
/**
* Monitors the given scroll state and scrolls the editor to the state if changed.
@ -16,22 +32,21 @@ import type { ScrollState } from '../../synced-scroll/scroll-props'
* @param scrollState The scroll state that should be monitored
*/
export const useApplyScrollState = (
editorRef: MutableRefObject<Editor | undefined>,
editorRef: MutableRefObject<ReactCodeMirrorRef | null>,
scrollState?: ScrollState
): void => {
const lastScrollPosition = useRef<number>()
const lastScrollPosition = useRef<ScrollState>()
useEffect(() => {
const editor = editorRef.current
if (!editor || !scrollState) {
const view = editorRef.current?.view
if (!view || !scrollState) {
return
}
const startYOfLine = editor.heightAtLine(scrollState.firstLineInView - 1, 'local')
const heightOfLine = (editor.lineInfo(scrollState.firstLineInView - 1).handle as { height: number }).height
const newPositionRaw = startYOfLine + (heightOfLine * scrollState.scrolledPercentage) / 100
const newPosition = Math.floor(newPositionRaw)
if (newPosition !== lastScrollPosition.current) {
lastScrollPosition.current = newPosition
editor.scrollTo(0, newPosition)
if (equal(scrollState, lastScrollPosition.current)) {
return
}
applyScrollState(view, scrollState)
lastScrollPosition.current = scrollState
}, [editorRef, scrollState])
}

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RefObject } from 'react'
import { useMemo, useRef } from 'react'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
/**
* Creates a {@link RefObject<boolean> reference} that contains the information if the editor is currently focused or not.
*
* @returns The reference and the necessary {@link Extension code mirror extension} that receives the focus and blur events
*/
export const useCodeMirrorFocusReference = (): [Extension, RefObject<boolean>] => {
const focusReference = useRef<boolean>(false)
const codeMirrorExtension = useMemo(
() =>
EditorView.domEventHandlers({
blur: () => {
focusReference.current = false
},
focus: () => {
focusReference.current = true
}
}),
[]
)
return [codeMirrorExtension, focusReference]
}

View file

@ -1,48 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { EditorConfiguration } from 'codemirror'
import { useMemo } from 'react'
import { createDefaultKeyMap } from '../key-map'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { useTranslation } from 'react-i18next'
/**
* Generates the configuration for a CodeMirror instance.
*/
export const useCodeMirrorOptions = (): EditorConfiguration => {
const editorPreferences = useApplicationState((state) => state.editorConfig.preferences)
const { t } = useTranslation()
return useMemo<EditorConfiguration>(
() => ({
...editorPreferences,
mode: 'gfm',
viewportMargin: 20,
styleActiveLine: true,
lineNumbers: true,
lineWrapping: true,
showCursorWhenSelecting: true,
highlightSelectionMatches: true,
inputStyle: 'textarea',
matchBrackets: true,
autoCloseBrackets: true,
matchTags: {
bothTags: true
},
autoCloseTags: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'authorship-gutters', 'CodeMirror-foldgutter'],
extraKeys: createDefaultKeyMap(),
flattenSpans: true,
addModeClass: true,
autoRefresh: true,
// otherCursors: true,
placeholder: t('editor.placeholder')
}),
[t, editorPreferences]
)
}

View file

@ -1,35 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { StatusBarInfo } from '../status-bar/status-bar'
import { useMemo } from 'react'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Provides a {@link StatusBarInfo} object and a function that can update this object using a {@link CodeMirror code mirror instance}.
*/
export const useCreateStatusBarInfo = (): StatusBarInfo => {
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const selection = useApplicationState((state) => state.noteDetails.selection)
const markdownContent = useApplicationState((state) => state.noteDetails.markdownContent)
const markdownContentLines = useApplicationState((state) => state.noteDetails.markdownContentLines)
return useMemo(() => {
const startCharacter = selection.from.character
const endCharacter = selection.to?.character ?? 0
const startLine = selection.from.line
const endLine = selection.to?.line ?? 0
return {
position: { line: startLine, character: startCharacter },
charactersInDocument: markdownContent.length,
remainingCharacters: maxDocumentLength - markdownContent.length,
linesInDocument: markdownContentLines.length,
selectedColumns: endCharacter - startCharacter,
selectedLines: endLine - startLine
}
}, [markdownContent.length, markdownContentLines.length, maxDocumentLength, selection])
}

View file

@ -4,27 +4,36 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import { useCallback } from 'react'
import type { CursorPosition } from '../../../../redux/editor/types'
import type { RefObject } from 'react'
import { useMemo } from 'react'
import { updateCursorPositions } from '../../../../redux/note-details/methods'
import type { ViewUpdate } from '@codemirror/view'
import { EditorView } from '@codemirror/view'
import { Logger } from '../../../../utils/logger'
import type { Extension } from '@codemirror/state'
const logger = new Logger('useCursorActivityCallback')
/**
* Provides a callback for codemirror that handles cursor changes
*
* @return the generated callback
*/
export const useCursorActivityCallback = (): ((editor: Editor) => void) => {
return useCallback((editor) => {
const firstSelection = editor.listSelections()[0]
if (firstSelection === undefined) {
return
}
const start: CursorPosition = { line: firstSelection.from().line, character: firstSelection.from().ch }
const end: CursorPosition = { line: firstSelection.to().line, character: firstSelection.to().ch }
updateCursorPositions({
from: start,
to: start.line === end.line && start.character === end.character ? undefined : end
})
}, [])
export const useCursorActivityCallback = (editorFocused: RefObject<boolean>): Extension => {
return useMemo(
() =>
EditorView.updateListener.of((viewUpdate: ViewUpdate): void => {
if (!editorFocused.current) {
logger.debug("Don't post updated cursor because editor isn't focused")
return
}
const firstSelection = viewUpdate.state.selection.main
const newCursorPos = {
from: firstSelection.from,
to: firstSelection.to === firstSelection.from ? undefined : firstSelection.to
}
updateCursorPositions(newCursorPos)
}),
[editorFocused]
)
}

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
export interface LineBasedPosition {
line: number
character: number
}
const calculateLineBasedPosition = (absolutePosition: number, lineStartIndexes: number[]): LineBasedPosition => {
const foundLineIndex = lineStartIndexes.findIndex((startIndex) => absolutePosition < startIndex)
const line = foundLineIndex === -1 ? lineStartIndexes.length - 1 : foundLineIndex - 1
return {
line: line,
character: absolutePosition - lineStartIndexes[line]
}
}
/**
* Returns the line+character based position of the to cursor, if available.
*/
export const useLineBasedToPosition = (): LineBasedPosition | undefined => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes)
const selection = useApplicationState((state) => state.noteDetails.selection)
return useMemo(() => {
const to = selection.to
if (to === undefined) {
return undefined
}
return calculateLineBasedPosition(to, lineStartIndexes)
}, [selection.to, lineStartIndexes])
}
/**
* Returns the line+character based position of the from cursor.
*/
export const useLineBasedFromPosition = (): LineBasedPosition => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes)
const selection = useApplicationState((state) => state.noteDetails.selection)
return useMemo(() => {
return calculateLineBasedPosition(selection.from, lineStartIndexes)
}, [selection.from, lineStartIndexes])
}

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo, useRef } from 'react'
import type { ScrollState } from '../../synced-scroll/scroll-props'
import { extractScrollState } from './code-mirror-extensions/use-code-mirror-scroll-watch-extension'
import { applyScrollState } from './use-apply-scroll-state'
import { store } from '../../../../redux'
import type { Extension } from '@codemirror/state'
import { Logger } from '../../../../utils/logger'
import { EditorView } from '@codemirror/view'
const logger = new Logger('useOffScreenScrollProtection')
/**
* If the editor content changes while the editor isn't focused then the editor starts jumping around.
* This extension fixes this behaviour by saving the scroll state when the editor looses focus and applies it on content changes.
*
* @returns necessary {@link Extension code mirror extensions} to provide the functionality
*/
export const useOffScreenScrollProtection = (): Extension[] => {
const offFocusScrollState = useRef<ScrollState>()
return useMemo(() => {
const saveOffFocusScrollStateExtension = EditorView.domEventHandlers({
blur: (event, view) => {
offFocusScrollState.current = extractScrollState(view)
logger.debug('Save off-focus scroll state', offFocusScrollState.current)
},
focus: () => {
offFocusScrollState.current = undefined
}
})
const changeExtension = EditorView.updateListener.of((update) => {
const view = update.view
const scrollState = offFocusScrollState.current
if (!scrollState || !update.docChanged) {
return
}
logger.debug('Apply off-focus scroll state', scrollState)
applyScrollState(view, scrollState)
const selection = store.getState().noteDetails.selection
view.dispatch(
view.state.update({
selection: {
anchor: selection.from,
head: selection.to
}
})
)
})
return [saveOffFocusScrollStateExtension, changeExtension]
}, [])
}

View file

@ -1,45 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback } from 'react'
import type { Editor } from 'codemirror'
import { handleUpload } from '../upload-handler'
import type { DomEvent } from 'react-codemirror2'
interface DropEvent {
pageX: number
pageY: number
dataTransfer: {
files: FileList
effectAllowed: string
} | null
preventDefault: () => void
}
/**
* Creates a callback that is used to process file drops on the code mirror editor
*
* @return the code mirror callback
*/
export const useOnEditorFileDrop = (): DomEvent => {
return useCallback((dropEditor: Editor, event: DropEvent) => {
if (
event &&
dropEditor &&
event.pageX &&
event.pageY &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length >= 1
) {
event.preventDefault()
const newCursor = dropEditor.coordsChar({ top: event.pageY, left: event.pageX }, 'page')
dropEditor.setCursor(newCursor)
const files: FileList = event.dataTransfer.files
handleUpload(files[0])
}
}, [])
}

View file

@ -1,28 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback } from 'react'
import type { Editor } from 'codemirror'
import type { PasteEvent } from '../tool-bar/utils/pasteHandlers'
import { handleFilePaste, handleTablePaste } from '../tool-bar/utils/pasteHandlers'
import type { DomEvent } from 'react-codemirror2'
/**
* Creates a callback that handles the table or file paste action in code mirror.
*
* @return the created callback
*/
export const useOnEditorPasteCallback = (): DomEvent => {
return useCallback((pasteEditor: Editor, event: PasteEvent) => {
if (!event || !event.clipboardData) {
return
}
if (handleTablePaste(event) || handleFilePaste(event)) {
event.preventDefault()
return
}
}, [])
}

View file

@ -1,47 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DomEvent } from 'react-codemirror2'
import { useCallback, useEffect, useState } from 'react'
import type { Editor, ScrollInfo } from 'codemirror'
import type { ScrollState } from '../../synced-scroll/scroll-props'
/**
* Creates a callback for the scroll binding of the code mirror editor.
* It calculates a {@link ScrollState} and posts it on change.
*
* @param onScroll The callback that is used to post the {@link ScrolLState}.
* @return The callback for the code mirror scroll binding.
*/
export const useOnEditorScroll = (onScroll?: (scrollState: ScrollState) => void): DomEvent => {
const [editorScrollState, setEditorScrollState] = useState<ScrollState>()
useEffect(() => {
if (onScroll && editorScrollState) {
onScroll(editorScrollState)
}
}, [editorScrollState, onScroll])
return useCallback(
(editor: Editor, scrollInfo: ScrollInfo) => {
if (!editor || !onScroll || !scrollInfo) {
return
}
const line = editor.lineAtHeight(scrollInfo.top, 'local')
const startYOfLine = editor.heightAtLine(line, 'local')
const lineInfo = editor.lineInfo(line)
if (lineInfo === null) {
return
}
const heightOfLine = (lineInfo.handle as { height: number }).height
const percentageRaw = Math.max(scrollInfo.top - startYOfLine, 0) / heightOfLine
const percentage = Math.floor(percentageRaw * 100)
setEditorScrollState({ firstLineInView: line + 1, scrolledPercentage: percentage })
},
[onScroll]
)
}

View file

@ -36,7 +36,7 @@ export const useOnImageUploadFromRenderer = (): void => {
.then((blob) => {
const file = new File([blob], fileName, { type: blob.type })
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex)
.map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.flatMap((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.orElseGet(() => ({}))
handleUpload(file, cursorSelection, alt, title)
})
@ -58,26 +58,25 @@ export interface ExtractResult {
* @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder.
* @return the calculated start and end position or undefined if no position could be determined
*/
const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): ExtractResult | undefined => {
const currentMarkdownContentLines = getGlobalState().noteDetails.markdownContent.split('\n')
const lineAtIndex = currentMarkdownContentLines[lineIndex]
if (lineAtIndex === undefined) {
return
}
return findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], lineIndex, replacementIndexInLine)
const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): Optional<ExtractResult> => {
const noteDetails = getGlobalState().noteDetails
const currentMarkdownContentLines = noteDetails.markdownContent.lines
return Optional.ofNullable(noteDetails.markdownContent.lineStartIndexes[lineIndex]).map((startIndexOfLine) =>
findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], startIndexOfLine, replacementIndexInLine)
)
}
/**
* Tries to find the right image placeholder in the given line.
*
* @param line The line that should be inspected
* @param lineIndex The index of the line in the document
* @param startIndexOfLine The absolute start index of the line in the document
* @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder.
* @return the calculated start and end position or undefined if no position could be determined
*/
const findImagePlaceholderInLine = (
line: string,
lineIndex: number,
startIndexOfLine: number,
replacementIndexInLine = 0
): ExtractResult | undefined => {
const startOfImageTag = findRegexMatchInText(line, imageWithPlaceholderLinkRegex, replacementIndexInLine)
@ -85,16 +84,12 @@ const findImagePlaceholderInLine = (
return
}
const from = startIndexOfLine + startOfImageTag.index
const to = from + startOfImageTag[0].length
return {
cursorSelection: {
from: {
character: startOfImageTag.index,
line: lineIndex
},
to: {
character: startOfImageTag.index + startOfImageTag[0].length,
line: lineIndex
}
from,
to
},
alt: startOfImageTag[1],
title: startOfImageTag[2]

View file

@ -1,104 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, KeyMap, Pass } from 'codemirror'
import CodeMirror from 'codemirror'
import { isMac } from '../utils'
import { formatSelection } from '../../../redux/note-details/methods'
import { FormatType } from '../../../redux/note-details/types'
const isVim = (keyMapName?: string) => keyMapName?.substr(0, 3) === 'vim'
const f10 = (editor: Editor): void | typeof Pass => editor.setOption('fullScreen', !editor.getOption('fullScreen'))
const esc = (editor: Editor): void | typeof Pass => {
if (editor.getOption('fullScreen') && !isVim(editor.getOption('keyMap'))) {
editor.setOption('fullScreen', false)
} else {
return CodeMirror.Pass
}
}
const suppressKey = (): void => {
/*no op*/
}
const tab = (editor: Editor) => {
const tab = '\t'
// contruct x length spaces
const spaces = Array((editor.getOption('indentUnit') ?? 0) + 1).join(' ')
// auto indent whole line when in list or blockquote
const cursor = editor.getCursor()
const line = editor.getLine(cursor.line)
// this regex match the following patterns
// 1. blockquote starts with "> " or ">>"
// 2. unorder list starts with *+-parseInt
// 3. order list starts with "1." or "1)"
const regex = /^(\s*)(>[> ]*|[*+-]\s|(\d+)([.)]))/
let match
const multiple = editor.getSelection().split('\n').length > 1 || editor.getSelections().length > 1
if (multiple) {
editor.execCommand('defaultTab')
} else if ((match = regex.exec(line)) !== null) {
const ch = match[1].length
const pos = {
line: cursor.line,
ch: ch
}
if (editor.getOption('indentWithTabs')) {
editor.replaceRange(tab, pos, pos, '+input')
} else {
editor.replaceRange(spaces, pos, pos, '+input')
}
} else {
if (editor.getOption('indentWithTabs')) {
editor.execCommand('defaultTab')
} else {
editor.replaceSelection(spaces)
}
}
}
export const createDefaultKeyMap: () => KeyMap = () => {
if (isMac()) {
return {
F9: suppressKey,
F10: f10,
Esc: esc,
'Cmd-S': suppressKey,
Enter: 'newlineAndIndentContinueMarkdownList',
Tab: tab,
'Cmd-Left': 'goLineLeftSmart',
'Cmd-Right': 'goLineRight',
Home: 'goLineLeftSmart',
End: 'goLineRight',
'Cmd-I': () => formatSelection(FormatType.ITALIC),
'Cmd-B': () => formatSelection(FormatType.BOLD),
'Cmd-U': () => formatSelection(FormatType.UNDERLINE),
'Cmd-D': () => formatSelection(FormatType.STRIKETHROUGH),
'Cmd-M': () => formatSelection(FormatType.HIGHLIGHT)
} as KeyMap
} else {
return {
F9: suppressKey,
F10: f10,
Esc: esc,
'Ctrl-S': suppressKey,
Enter: 'newlineAndIndentContinueMarkdownList',
Tab: tab,
Home: 'goLineLeftSmart',
End: 'goLineRight',
'Ctrl-I': () => formatSelection(FormatType.ITALIC),
'Ctrl-B': () => formatSelection(FormatType.BOLD),
'Ctrl-U': () => formatSelection(FormatType.UNDERLINE),
'Ctrl-D': () => formatSelection(FormatType.STRIKETHROUGH),
'Ctrl-M': () => formatSelection(FormatType.HIGHLIGHT),
'Ctrl-K': () => formatSelection(FormatType.LINK)
} as KeyMap
}
}

View file

@ -6,27 +6,23 @@
import React, { useMemo } from 'react'
import { Trans } from 'react-i18next'
import type { CursorPosition } from '../../../../redux/editor/types'
export interface CursorPositionInfoProps {
cursorPosition: CursorPosition
}
import { useLineBasedFromPosition } from '../hooks/use-line-based-position'
/**
* Renders a translated text that shows the given cursor position.
*
* @param cursorPosition The cursor position that should be included
*/
export const CursorPositionInfo: React.FC<CursorPositionInfoProps> = ({ cursorPosition }) => {
export const CursorPositionInfo: React.FC = () => {
const lineBasedPosition = useLineBasedFromPosition()
const translationOptions = useMemo(
() => ({
line: cursorPosition.line + 1,
columns: cursorPosition.character + 1
line: lineBasedPosition.line + 1,
columns: lineBasedPosition.character + 1
}),
[cursorPosition.character, cursorPosition.line]
[lineBasedPosition]
)
return (
return translationOptions === undefined ? null : (
<span>
<Trans i18nKey={'editor.statusBar.cursor'} values={translationOptions} />
</span>

View file

@ -6,20 +6,18 @@
import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
export interface LinesInDocumentInfoProps {
numberOfLinesInDocument: number
}
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Renders a translated text that shows the number of lines in the document.
*
* @param linesInDocument The number of lines in the document
*/
export const NumberOfLinesInDocumentInfo: React.FC<LinesInDocumentInfoProps> = ({ numberOfLinesInDocument }) => {
export const NumberOfLinesInDocumentInfo: React.FC = () => {
useTranslation()
const translationOptions = useMemo(() => ({ lines: numberOfLinesInDocument }), [numberOfLinesInDocument])
const linesInDocument = useApplicationState((state) => state.noteDetails.markdownContent.lines.length)
const translationOptions = useMemo(() => ({ lines: linesInDocument }), [linesInDocument])
return (
<span>

View file

@ -7,11 +7,7 @@
import React, { useMemo } from 'react'
import { cypressId } from '../../../../utils/cypress-attribute'
import { Trans, useTranslation } from 'react-i18next'
export interface LengthInfoProps {
remainingCharacters: number
charactersInDocument: number
}
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Renders a translated text that shows the number of remaining characters.
@ -19,9 +15,13 @@ export interface LengthInfoProps {
* @param remainingCharacters The number of characters that are still available in this document
* @param charactersInDocument The total number of characters in the document
*/
export const RemainingCharactersInfo: React.FC<LengthInfoProps> = ({ remainingCharacters, charactersInDocument }) => {
export const RemainingCharactersInfo: React.FC = () => {
const { t } = useTranslation()
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const contentLength = useApplicationState((state) => state.noteDetails.markdownContent.plain.length)
const remainingCharacters = useMemo(() => maxDocumentLength - contentLength, [contentLength, maxDocumentLength])
const remainingCharactersClass = useMemo(() => {
if (remainingCharacters <= 0) {
return 'text-danger'
@ -42,7 +42,7 @@ export const RemainingCharactersInfo: React.FC<LengthInfoProps> = ({ remainingCh
}
}, [remainingCharacters, t])
const translationOptions = useMemo(() => ({ length: charactersInDocument }), [charactersInDocument])
const translationOptions = useMemo(() => ({ length: contentLength }), [contentLength])
return (
<span {...cypressId('remainingCharacters')} title={lengthTooltip} className={remainingCharactersClass}>

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useMemo } from 'react'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { SeparatorDash } from './separator-dash'
import { Trans, useTranslation } from 'react-i18next'
/**
* Shows the total number of selected characters.
*/
export const SelectedCharacters: React.FC = () => {
useTranslation()
const selection = useApplicationState((state) => state.noteDetails.selection)
const count = useMemo(
() => (selection.to === undefined ? undefined : selection.to - selection.from),
[selection.from, selection.to]
)
const countTranslationOptions = useMemo(() => ({ count }), [count])
return count === undefined ? null : (
<Fragment>
<SeparatorDash />
<span>
<Trans i18nKey={`editor.statusBar.selection.characters`} values={countTranslationOptions} />
</span>
</Fragment>
)
}

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useMemo } from 'react'
import { SeparatorDash } from './separator-dash'
import { Trans, useTranslation } from 'react-i18next'
import { useLineBasedFromPosition, useLineBasedToPosition } from '../hooks/use-line-based-position'
/**
* Shows the total number of selected lines.
*/
export const SelectedLines: React.FC = () => {
useTranslation()
const from = useLineBasedFromPosition()
const to = useLineBasedToPosition()
const count = useMemo(() => (to ? to?.line - from.line + 1 : 0), [from.line, to])
const countTranslationOptions = useMemo(() => ({ count }), [count])
return count <= 1 ? null : (
<Fragment>
<SeparatorDash />
<span>
<Trans i18nKey={`editor.statusBar.selection.lines`} values={countTranslationOptions} />
</span>
</Fragment>
)
}

View file

@ -9,56 +9,25 @@ import styles from './status-bar.module.scss'
import { RemainingCharactersInfo } from './remaining-characters-info'
import { NumberOfLinesInDocumentInfo } from './number-of-lines-in-document-info'
import { CursorPositionInfo } from './cursor-position-info'
import { SelectionInfo } from './selection-info'
import { ShowIf } from '../../../common/show-if/show-if'
import { SeparatorDash } from './separator-dash'
import type { CursorPosition } from '../../../../redux/editor/types'
import { useCreateStatusBarInfo } from '../hooks/use-create-status-bar-info'
export interface StatusBarInfo {
position: CursorPosition
selectedColumns: number
selectedLines: number
linesInDocument: number
charactersInDocument: number
remainingCharacters: number
}
export const defaultState: StatusBarInfo = {
position: { line: 0, character: 0 },
selectedColumns: 0,
selectedLines: 0,
linesInDocument: 0,
charactersInDocument: 0,
remainingCharacters: 0
}
import { SelectedCharacters } from './selected-characters'
import { SelectedLines } from './selected-lines'
/**
* Shows additional information about the document length and the current selection.
*/
export const StatusBar: React.FC = () => {
const statusBarInfo = useCreateStatusBarInfo()
return (
<div className={`d-flex flex-row ${styles['status-bar']} px-2`}>
<div>
<CursorPositionInfo cursorPosition={statusBarInfo.position} />
<ShowIf condition={statusBarInfo.selectedLines === 1 && statusBarInfo.selectedColumns > 0}>
<SeparatorDash />
<SelectionInfo count={statusBarInfo.selectedColumns} translationKey={'column'} />
</ShowIf>
<ShowIf condition={statusBarInfo.selectedLines > 1}>
<SeparatorDash />
<SelectionInfo count={statusBarInfo.selectedLines} translationKey={'line'} />
</ShowIf>
<CursorPositionInfo />
<SelectedCharacters />
<SelectedLines />
</div>
<div className='ml-auto'>
<NumberOfLinesInDocumentInfo numberOfLinesInDocument={statusBarInfo.linesInDocument} />
<NumberOfLinesInDocumentInfo />
<SeparatorDash />
<RemainingCharactersInfo
remainingCharacters={statusBarInfo.remainingCharacters}
charactersInDocument={statusBarInfo.charactersInDocument}
/>
<RemainingCharactersInfo />
</div>
</div>
)

View file

@ -1,47 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { EditorConfiguration } from 'codemirror'
import type { ChangeEvent } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mergeEditorPreferences } from '../../../../../redux/editor/methods'
import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input'
import type { EditorPreferenceProperty } from './editor-preference-property'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
export interface EditorPreferenceBooleanProps {
property: EditorPreferenceProperty
}
export const EditorPreferenceBooleanProperty: React.FC<EditorPreferenceBooleanProps> = ({ property }) => {
const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '')
const { t } = useTranslation()
const selectItem = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const selectedItem: boolean = event.target.value === 'true'
mergeEditorPreferences({
[property]: selectedItem
} as EditorConfiguration)
},
[property]
)
const i18nPrefix = `editor.modal.preferences.${property}`
return (
<EditorPreferenceInput
onChange={selectItem}
property={property}
type={EditorPreferenceInputType.SELECT}
value={preference}>
<option value={'true'}>{t(`${i18nPrefix}.on`)}</option>
<option value={'false'}>{t(`${i18nPrefix}.off`)}</option>
</EditorPreferenceInput>
)
}

View file

@ -1,48 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
export enum EditorPreferenceInputType {
SELECT,
BOOLEAN,
NUMBER
}
export interface EditorPreferenceInputProps {
property: string
type: EditorPreferenceInputType
onChange: React.ChangeEventHandler<HTMLSelectElement>
value?: string | number | string[]
}
export const EditorPreferenceInput: React.FC<EditorPreferenceInputProps> = ({
property,
type,
onChange,
value,
children
}) => {
useTranslation()
return (
<Form.Group controlId={`editor-pref-${property}`}>
<Form.Label>
<Trans
i18nKey={`editor.modal.preferences.${property}${type === EditorPreferenceInputType.NUMBER ? '' : '.label'}`}
/>
</Form.Label>
<Form.Control
as={type === EditorPreferenceInputType.NUMBER ? 'input' : 'select'}
size='sm'
value={value}
onChange={onChange}
type={type === EditorPreferenceInputType.NUMBER ? 'number' : ''}>
{children}
</Form.Control>
</Form.Group>
)
}

View file

@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ChangeEvent } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { setEditorLigatures } from '../../../../../redux/editor/methods'
import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
export const EditorPreferenceLigaturesSelect: React.FC = () => {
const ligaturesEnabled = useApplicationState((state) => Boolean(state.editorConfig.ligatures).toString())
const saveLigatures = useCallback((event: ChangeEvent<HTMLSelectElement>) => {
const ligaturesActivated: boolean = event.target.value === 'true'
setEditorLigatures(ligaturesActivated)
}, [])
const { t } = useTranslation()
return (
<EditorPreferenceInput
onChange={saveLigatures}
value={ligaturesEnabled}
property={'ligatures'}
type={EditorPreferenceInputType.BOOLEAN}>
<option value='true'>{t(`common.yes`)}</option>
<option value='false'>{t(`common.no`)}</option>
</EditorPreferenceInput>
)
}

View file

@ -1,41 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { EditorConfiguration } from 'codemirror'
import type { ChangeEvent } from 'react'
import React, { useCallback } from 'react'
import { mergeEditorPreferences } from '../../../../../redux/editor/methods'
import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input'
import type { EditorPreferenceProperty } from './editor-preference-property'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
export interface EditorPreferenceNumberProps {
property: EditorPreferenceProperty
}
export const EditorPreferenceNumberProperty: React.FC<EditorPreferenceNumberProps> = ({ property }) => {
const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '')
const selectItem = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const selectedItem: number = Number.parseInt(event.target.value)
mergeEditorPreferences({
[property]: selectedItem
} as EditorConfiguration)
},
[property]
)
return (
<EditorPreferenceInput
onChange={selectItem}
property={property}
type={EditorPreferenceInputType.NUMBER}
value={preference}
/>
)
}

View file

@ -1,13 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum EditorPreferenceProperty {
KEYMAP = 'keyMap',
THEME = 'theme',
INDENT_WITH_TABS = 'indentWithTabs',
INDENT_UNIT = 'indentUnit',
SPELL_CHECK = 'spellcheck'
}

View file

@ -1,55 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { EditorConfiguration } from 'codemirror'
import type { ChangeEvent } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mergeEditorPreferences } from '../../../../../redux/editor/methods'
import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input'
import type { EditorPreferenceProperty } from './editor-preference-property'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
export interface EditorPreferenceSelectPropertyProps {
property: EditorPreferenceProperty
selections: string[]
}
export const EditorPreferenceSelectProperty: React.FC<EditorPreferenceSelectPropertyProps> = ({
property,
selections
}) => {
const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '')
const { t } = useTranslation()
const selectItem = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const selectedItem: string = event.target.value
mergeEditorPreferences({
[property]: selectedItem
} as EditorConfiguration)
},
[property]
)
const i18nPrefix = `editor.modal.preferences.${property}`
return (
<EditorPreferenceInput
onChange={selectItem}
property={property}
type={EditorPreferenceInputType.SELECT}
value={preference}>
{selections.map((selection) => (
<option key={selection} value={selection}>
{t(`${i18nPrefix}.${selection}`)}
</option>
))}
</EditorPreferenceInput>
)
}

View file

@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ChangeEvent } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { setEditorSmartPaste } from '../../../../../redux/editor/methods'
import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input'
export const EditorPreferenceSmartPasteSelect: React.FC = () => {
const smartPasteEnabled = useApplicationState((state) => Boolean(state.editorConfig.smartPaste).toString())
const saveSmartPaste = useCallback((event: ChangeEvent<HTMLSelectElement>) => {
const smartPasteActivated: boolean = event.target.value === 'true'
setEditorSmartPaste(smartPasteActivated)
}, [])
const { t } = useTranslation()
return (
<EditorPreferenceInput
onChange={saveSmartPaste}
value={smartPasteEnabled}
property={'smartPaste'}
type={EditorPreferenceInputType.BOOLEAN}>
<option value='true'>{t(`common.yes`)}</option>
<option value='false'>{t(`common.no`)}</option>
</EditorPreferenceInput>
)
}

View file

@ -1,80 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useState } from 'react'
import { Button, Form, ListGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { CommonModal } from '../../../../common/modals/common-modal'
import { ShowIf } from '../../../../common/show-if/show-if'
import { EditorPreferenceBooleanProperty } from './editor-preference-boolean-property'
import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input'
import { EditorPreferenceLigaturesSelect } from './editor-preference-ligatures-select'
import { EditorPreferenceNumberProperty } from './editor-preference-number-property'
import { EditorPreferenceProperty } from './editor-preference-property'
import { EditorPreferenceSelectProperty } from './editor-preference-select-property'
import { EditorPreferenceSmartPasteSelect } from './editor-preference-smart-paste-select'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
export const EditorPreferences: React.FC = () => {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const indentWithTabs = useApplicationState((state) => state.editorConfig.preferences.indentWithTabs ?? false)
return (
<Fragment>
<Button variant='light' onClick={() => setShowModal(true)} title={t('editor.editorToolbar.preferences')}>
<ForkAwesomeIcon icon='wrench' />
</Button>
<CommonModal
show={showModal}
onHide={() => setShowModal(false)}
title={'editor.modal.preferences.title'}
showCloseButton={true}
titleIcon={'wrench'}>
<Form>
<ListGroup>
<ListGroup.Item>
<EditorPreferenceSelectProperty
property={EditorPreferenceProperty.THEME}
selections={['one-dark', 'neat']}
/>
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceSelectProperty
property={EditorPreferenceProperty.KEYMAP}
selections={['sublime', 'emacs', 'vim']}
/>
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceBooleanProperty property={EditorPreferenceProperty.INDENT_WITH_TABS} />
</ListGroup.Item>
<ShowIf condition={!indentWithTabs}>
<ListGroup.Item>
<EditorPreferenceNumberProperty property={EditorPreferenceProperty.INDENT_UNIT} />
</ListGroup.Item>
</ShowIf>
<ListGroup.Item>
<EditorPreferenceLigaturesSelect />
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceSmartPasteSelect />
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceInput
onChange={() => alert('This feature is not yet implemented.')}
property={EditorPreferenceProperty.SPELL_CHECK}
type={EditorPreferenceInputType.SELECT}>
<option value='off'>Off</option>
<option value='en'>English</option>
</EditorPreferenceInput>
</ListGroup.Item>
</ListGroup>
</Form>
</CommonModal>
</Fragment>
)
}

View file

@ -6,7 +6,6 @@
import React from 'react'
import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
import { EditorPreferences } from './editor-preferences/editor-preferences'
import { EmojiPickerButton } from './emoji-picker/emoji-picker-button'
import { TablePickerButton } from './table-picker/table-picker-button'
import styles from './tool-bar.module.scss'
@ -46,9 +45,6 @@ export const ToolBar: React.FC = () => {
<ToolbarButton icon={'comment'} formatType={FormatType.COMMENT} />
<EmojiPickerButton />
</ButtonGroup>
<ButtonGroup className={'mx-1 flex-wrap'}>
<EditorPreferences />
</ButtonGroup>
</ButtonToolbar>
)
}

View file

@ -13,20 +13,19 @@ import { Mock } from 'ts-mockery'
describe('Check whether cursor is in codefence', () => {
const getGlobalStateMocked = jest.spyOn(storeModule, 'getGlobalState')
const mockRedux = (content: string, line: number): void => {
const mockRedux = (content: string, from: number): void => {
const contentLines = content.split('\n')
getGlobalStateMocked.mockImplementation(() =>
Mock.from<ApplicationState>({
noteDetails: {
...initialState,
selection: {
from: {
line: line,
character: 0
}
from
},
markdownContentLines: contentLines,
markdownContent: content
markdownContent: {
plain: content,
lines: contentLines
}
}
})
)
@ -46,22 +45,22 @@ describe('Check whether cursor is in codefence', () => {
})
it('returns true with one open codefence directly above', () => {
mockRedux('```\n', 1)
mockRedux('```\n', 4)
expect(isCursorInCodeFence()).toBe(true)
})
it('returns true with one open codefence and empty lines above', () => {
mockRedux('```\n\n\n', 3)
mockRedux('```\n\n\n', 5)
expect(isCursorInCodeFence()).toBe(true)
})
it('returns false with one completed codefence above', () => {
mockRedux('```\n\n```\n', 3)
mockRedux('```\n\n```\n', 8)
expect(isCursorInCodeFence()).toBe(false)
})
it('returns true with one completed and one open codefence above', () => {
mockRedux('```\n\n```\n\n```\n\n', 6)
mockRedux('```\n\n```\n\n```\n\n', 13)
expect(isCursorInCodeFence()).toBe(true)
})
})

View file

@ -10,10 +10,8 @@ import { getGlobalState } from '../../../../../redux'
* Checks if the start of the current {@link CursorSelection cursor selection} is in a code fence.
*/
export const isCursorInCodeFence = (): boolean => {
const lines = getGlobalState().noteDetails.markdownContentLines.slice(
0,
getGlobalState().noteDetails.selection.from.line
)
const noteDetails = getGlobalState().noteDetails
const lines = noteDetails.markdownContent.plain.slice(0, noteDetails.selection.from).split('\n')
return countCodeFenceLinesUntilIndex(lines) % 2 === 1
}

View file

@ -22,21 +22,20 @@ export interface PasteEvent {
}
/**
* Checks if the given {@link PasteEvent paste event} contains a text formatted table
* and inserts it into the markdown content.
* This happens only if smart paste was activated.
* Checks if the given {@link DataTransfer clipboard data} contains a text formatted table
* and inserts it into the markdown content. This happens only if smart paste is activated.
*
* @param event The {@link PasteEvent} from the browser
* @param clipboardData The {@link DataTransfer} from the paste event
* @return {@code true} if the event was processed. {@code false} otherwise
*/
export const handleTablePaste = (event: PasteEvent): boolean => {
export const handleTablePaste = (clipboardData: DataTransfer): boolean => {
if (!getGlobalState().editorConfig.smartPaste || isCursorInCodeFence()) {
return false
}
return Optional.ofNullable(event.clipboardData.getData('text'))
.filter((pasteText) => !!pasteText && isTable(pasteText))
.map((pasteText) => convertClipboardTableToMarkdown(pasteText))
return Optional.ofNullable(clipboardData.getData('text'))
.filter(isTable)
.map(convertClipboardTableToMarkdown)
.map((markdownTable) => {
replaceSelection(markdownTable)
return true
@ -47,12 +46,12 @@ export const handleTablePaste = (event: PasteEvent): boolean => {
/**
* Checks if the given {@link PasteEvent paste event} contains files and uploads them.
*
* @param event The {@link PasteEvent} from the browser
* @param clipboardData The {@link DataTransfer} from the paste event
* @return {@code true} if the event was processed. {@code false} otherwise
*/
export const handleFilePaste = (event: PasteEvent): boolean => {
return Optional.ofNullable(event.clipboardData.files)
.filter((files) => !!files && files.length > 0)
export const handleFilePaste = (clipboardData: DataTransfer): boolean => {
return Optional.of(clipboardData.files)
.filter((files) => files.length > 0)
.map((files) => {
handleUpload(files[0])
return true

View file

@ -86,7 +86,7 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
)
useEditorReceiveHandler(
CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER,
CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE,
useCallback(() => onMakeScrollSource?.(), [onMakeScrollSource])
)

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import type { ScrollState } from '../editor-page/synced-scroll/scroll-props'
import type { BaseConfiguration } from './window-post-message-communicator/rendering-message'
import { CommunicationMessageType, RendererType } from './window-post-message-communicator/rendering-message'
@ -27,26 +27,50 @@ export const IframeMarkdownRenderer: React.FC = () => {
const communicator = useRendererToEditorCommunicator()
const countWordsInRenderedDocument = useCallback(() => {
const documentContainer = document.querySelector('[data-word-count-target]')
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.ON_WORD_COUNT_CALCULATED,
words: documentContainer ? countWords(documentContainer) : 0
})
}, [communicator])
const sendScrolling = useRef<boolean>(false)
useRendererReceiveHandler(CommunicationMessageType.SET_BASE_CONFIGURATION, (values) =>
setBaseConfiguration(values.baseConfiguration)
useRendererReceiveHandler(
CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE,
useCallback(() => {
sendScrolling.current = false
}, [])
)
useRendererReceiveHandler(CommunicationMessageType.SET_MARKDOWN_CONTENT, (values) =>
setMarkdownContentLines(values.content)
useRendererReceiveHandler(
CommunicationMessageType.SET_BASE_CONFIGURATION,
useCallback((values) => setBaseConfiguration(values.baseConfiguration), [])
)
useRendererReceiveHandler(CommunicationMessageType.SET_DARKMODE, (values) => setDarkMode(values.activated))
useRendererReceiveHandler(CommunicationMessageType.SET_SCROLL_STATE, (values) => setScrollState(values.scrollState))
useRendererReceiveHandler(CommunicationMessageType.SET_FRONTMATTER_INFO, (values) =>
setFrontmatterInfo(values.frontmatterInfo)
useRendererReceiveHandler(
CommunicationMessageType.SET_MARKDOWN_CONTENT,
useCallback((values) => setMarkdownContentLines(values.content), [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_DARKMODE,
useCallback((values) => setDarkMode(values.activated), [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_SCROLL_STATE,
useCallback((values) => setScrollState(values.scrollState), [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_FRONTMATTER_INFO,
useCallback((values) => setFrontmatterInfo(values.frontmatterInfo), [])
)
useRendererReceiveHandler(
CommunicationMessageType.GET_WORD_COUNT,
useCallback(() => {
const documentContainer = document.querySelector('[data-word-count-target]')
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.ON_WORD_COUNT_CALCULATED,
words: documentContainer ? countWords(documentContainer) : 0
})
}, [communicator])
)
useRendererReceiveHandler(CommunicationMessageType.GET_WORD_COUNT, () => countWordsInRenderedDocument())
const onTaskCheckedChange = useCallback(
(lineInMarkdown: number, checked: boolean) => {
@ -70,13 +94,17 @@ export const IframeMarkdownRenderer: React.FC = () => {
)
const onMakeScrollSource = useCallback(() => {
sendScrolling.current = true
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER
type: CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE
})
}, [communicator])
const onScroll = useCallback(
(scrollState: ScrollState) => {
if (!sendScrolling.current) {
return
}
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.SET_SCROLL_STATE,
scrollState

View file

@ -7,7 +7,7 @@
import { useEffect } from 'react'
import type { CommunicationMessages, RendererToEditorMessageType } from '../rendering-message'
import { useEditorToRendererCommunicator } from '../../../editor-page/render-context/editor-to-renderer-communicator-context-provider'
import type { Handler } from '../window-post-message-communicator'
import type { MaybeHandler } from '../window-post-message-communicator'
/**
* Sets the handler for the given message type in the current editor to renderer communicator.
@ -17,7 +17,7 @@ import type { Handler } from '../window-post-message-communicator'
*/
export const useEditorReceiveHandler = <R extends RendererToEditorMessageType>(
messageType: R,
handler: Handler<CommunicationMessages, R>
handler: MaybeHandler<CommunicationMessages, R>
): void => {
const editorToRendererCommunicator = useEditorToRendererCommunicator()
useEffect(() => {

View file

@ -9,6 +9,11 @@ import type { CommunicationMessages, EditorToRendererMessageType } from '../rend
import type { Handler } from '../window-post-message-communicator'
import { useRendererToEditorCommunicator } from '../../../editor-page/render-context/renderer-to-editor-communicator-context-provider'
export type CommunicationMessageHandler<MESSAGE_TYPE extends EditorToRendererMessageType> = Handler<
CommunicationMessages,
MESSAGE_TYPE
>
/**
* Sets the handler for the given message type in the current renderer to editor communicator.
*
@ -17,7 +22,7 @@ import { useRendererToEditorCommunicator } from '../../../editor-page/render-con
*/
export const useRendererReceiveHandler = <MESSAGE_TYPE extends EditorToRendererMessageType>(
messageType: MESSAGE_TYPE,
handler: Handler<CommunicationMessages, MESSAGE_TYPE>
handler: CommunicationMessageHandler<MESSAGE_TYPE>
): void => {
const editorToRendererCommunicator = useRendererToEditorCommunicator()
useEffect(() => {

View file

@ -12,7 +12,8 @@ export enum CommunicationMessageType {
SET_DARKMODE = 'SET_DARKMODE',
ON_TASK_CHECKBOX_CHANGE = 'ON_TASK_CHECKBOX_CHANGE',
ON_FIRST_HEADING_CHANGE = 'ON_FIRST_HEADING_CHANGE',
SET_SCROLL_SOURCE_TO_RENDERER = 'SET_SCROLL_SOURCE_TO_RENDERER',
ENABLE_RENDERER_SCROLL_SOURCE = 'ENABLE_RENDERER_SCROLL_SOURCE',
DISABLE_RENDERER_SCROLL_SOURCE = 'DISABLE_RENDERER_SCROLL_SOURCE',
SET_SCROLL_STATE = 'SET_SCROLL_STATE',
IMAGE_CLICKED = 'IMAGE_CLICKED',
ON_HEIGHT_CHANGE = 'ON_HEIGHT_CHANGE',
@ -23,8 +24,8 @@ export enum CommunicationMessageType {
IMAGE_UPLOAD = 'IMAGE_UPLOAD'
}
export interface NoPayloadMessage {
type: CommunicationMessageType.RENDERER_READY | CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER
export interface NoPayloadMessage<TYPE extends CommunicationMessageType> {
type: TYPE
}
export interface SetDarkModeMessage {
@ -97,7 +98,9 @@ export interface OnWordCountCalculatedMessage {
}
export type CommunicationMessages =
| NoPayloadMessage
| NoPayloadMessage<CommunicationMessageType.RENDERER_READY>
| NoPayloadMessage<CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE>
| NoPayloadMessage<CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE>
| SetDarkModeMessage
| SetBaseUrlMessage
| GetWordCountMessage
@ -118,10 +121,11 @@ export type EditorToRendererMessageType =
| CommunicationMessageType.SET_BASE_CONFIGURATION
| CommunicationMessageType.GET_WORD_COUNT
| CommunicationMessageType.SET_FRONTMATTER_INFO
| CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE
export type RendererToEditorMessageType =
| CommunicationMessageType.RENDERER_READY
| CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER
| CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE
| CommunicationMessageType.ON_FIRST_HEADING_CHANGE
| CommunicationMessageType.ON_TASK_CHECKBOX_CHANGE
| CommunicationMessageType.SET_SCROLL_STATE

View file

@ -11,12 +11,14 @@ import type { Logger } from '../../../utils/logger'
*/
export class IframeCommunicatorSendingError extends Error {}
export type Handler<MESSAGES, MESSAGE_TYPE extends string> =
| ((values: Extract<MESSAGES, PostMessage<MESSAGE_TYPE>>) => void)
| undefined
export type Handler<MESSAGES, MESSAGE_TYPE extends string> = (
values: Extract<MESSAGES, PostMessage<MESSAGE_TYPE>>
) => void
export type MaybeHandler<MESSAGES, MESSAGE_TYPE extends string> = Handler<MESSAGES, MESSAGE_TYPE> | undefined
export type HandlerMap<MESSAGES, MESSAGE_TYPE extends string> = Partial<{
[key in MESSAGE_TYPE]: Handler<MESSAGES, MESSAGE_TYPE>
[key in MESSAGE_TYPE]: MaybeHandler<MESSAGES, MESSAGE_TYPE>
}>
export interface PostMessage<MESSAGE_TYPE extends string> {
@ -108,8 +110,9 @@ export abstract class WindowPostMessageCommunicator<
* @param messageType The message type for which the handler should be called
* @param handler The handler that processes messages with the given message type.
*/
public setHandler<R extends RECEIVE_TYPE>(messageType: R, handler: Handler<MESSAGES, R>): void {
this.handlers[messageType] = handler as Handler<MESSAGES, RECEIVE_TYPE>
public setHandler<R extends RECEIVE_TYPE>(messageType: R, handler: MaybeHandler<MESSAGES, R>): void {
this.log.debug('Set handler for', messageType)
this.handlers[messageType] = handler as MaybeHandler<MESSAGES, RECEIVE_TYPE>
}
/**