mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-29 06:15:29 -04:00
Move toolbar functionality from redux to codemirror dispatch (#2083)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
a8bd22aef3
commit
e93607c96e
99 changed files with 1730 additions and 1721 deletions
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const BoldButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return wrapSelection(currentSelection, '**', '**')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'bold'} iconName={'bold'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection'
|
||||
|
||||
export const CheckListButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return prependLinesOfSelection(markdownContent, currentSelection, () => `- [ ] `)
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'checkList'} iconName={'check-square'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { changeCursorsToWholeLineIfNoToCursor } from '../formatters/utils/change-cursors-to-whole-line-if-no-to-cursor'
|
||||
|
||||
export const CodeFenceButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return wrapSelection(changeCursorsToWholeLineIfNoToCursor(markdownContent, currentSelection), '```\n', '\n```')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'code'} iconName={'code'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import { changeCursorsToWholeLineIfNoToCursor } from '../formatters/utils/change-cursors-to-whole-line-if-no-to-cursor'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const CollapsibleBlockButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return wrapSelection(
|
||||
changeCursorsToWholeLineIfNoToCursor(markdownContent, currentSelection),
|
||||
'\n:::spoiler Toggle label\n',
|
||||
'\n:::\n'
|
||||
)
|
||||
}, [])
|
||||
return (
|
||||
<ToolbarButton i18nKey={'collapsibleBlock'} iconName={'caret-square-o-down'} formatter={formatter}></ToolbarButton>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { replaceSelection } from '../formatters/replace-selection'
|
||||
|
||||
export const CommentButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return replaceSelection({ from: currentSelection.to ?? currentSelection.from }, '> []', true)
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'comment'} iconName={'comment'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection'
|
||||
|
||||
export const HeaderLevelButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return prependLinesOfSelection(markdownContent, currentSelection, (line) => (line.startsWith('#') ? `#` : `# `))
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'header'} iconName={'header'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const HighlightButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return wrapSelection(currentSelection, '==', '==')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'highlight'} iconName={'eraser'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { replaceSelection } from '../formatters/replace-selection'
|
||||
|
||||
export const HorizontalLineButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return replaceSelection({ from: currentSelection.to ?? currentSelection.from }, '----\n', true)
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'horizontalLine'} iconName={'minus'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { addLink } from '../formatters/add-link'
|
||||
|
||||
export const ImageLinkButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return addLink(markdownContent, currentSelection, '!')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'imageLink'} iconName={'picture-o'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const ItalicButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return wrapSelection(currentSelection, '*', '*')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'italic'} iconName={'italic'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { addLink } from '../formatters/add-link'
|
||||
|
||||
export const LinkButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return addLink(markdownContent, currentSelection)
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'link'} iconName={'link'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection'
|
||||
|
||||
export const OrderedListButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return prependLinesOfSelection(
|
||||
markdownContent,
|
||||
currentSelection,
|
||||
(line, lineIndexInBlock) => `${lineIndexInBlock + 1}. `
|
||||
)
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'orderedList'} iconName={'list-ol'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection'
|
||||
|
||||
export const QuotesButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return prependLinesOfSelection(markdownContent, currentSelection, () => `> `)
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'blockquote'} iconName={'quote-right'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const StrikethroughButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return wrapSelection(currentSelection, '~~', '~~')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'strikethrough'} iconName={'strikethrough'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const SubscriptButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return wrapSelection(currentSelection, '~', '~')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'subscript'} iconName={'subscript'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const SuperscriptButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return wrapSelection(currentSelection, '^', '^')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'superscript'} iconName={'superscript'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { wrapSelection } from '../formatters/wrap-selection'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
|
||||
export const UnderlineButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection }) => {
|
||||
return wrapSelection(currentSelection, '++', '++')
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'underline'} iconName={'underline'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import type { ContentFormatter } from '../../../change-content-context/change-content-context'
|
||||
import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection'
|
||||
|
||||
export const UnorderedListButton: React.FC = () => {
|
||||
const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => {
|
||||
return prependLinesOfSelection(markdownContent, currentSelection, () => `- `)
|
||||
}, [])
|
||||
return <ToolbarButton i18nKey={'unorderedList'} iconName={'list'} formatter={formatter}></ToolbarButton>
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* 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
|
||||
*/
|
||||
|
@ -10,18 +10,26 @@ import { useTranslation } from 'react-i18next'
|
|||
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { EmojiPicker } from './emoji-picker'
|
||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||
import { getEmojiShortCode } from '../utils/emojiUtils'
|
||||
import { replaceSelection } from '../../../../../redux/note-details/methods'
|
||||
import type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
||||
import Optional from 'optional-js'
|
||||
import { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback'
|
||||
import { replaceSelection } from '../formatters/replace-selection'
|
||||
import { extractEmojiShortCode } from './extract-emoji-short-code'
|
||||
|
||||
export const EmojiPickerButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const onEmojiSelected = useCallback((emoji: EmojiClickEventDetail) => {
|
||||
setShowEmojiPicker(false)
|
||||
Optional.ofNullable(getEmojiShortCode(emoji)).ifPresent((shortCode) => replaceSelection(shortCode))
|
||||
}, [])
|
||||
const changeEditorContent = useChangeEditorContentCallback()
|
||||
|
||||
const onEmojiSelected = useCallback(
|
||||
(emojiClickEvent: EmojiClickEventDetail) => {
|
||||
setShowEmojiPicker(false)
|
||||
Optional.ofNullable(extractEmojiShortCode(emojiClickEvent)).ifPresent((shortCode) => {
|
||||
changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, shortCode, false))
|
||||
})
|
||||
},
|
||||
[changeEditorContent]
|
||||
)
|
||||
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
|
||||
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
|
||||
|
||||
|
@ -32,7 +40,8 @@ export const EmojiPickerButton: React.FC = () => {
|
|||
{...cypressId('show-emoji-picker')}
|
||||
variant='light'
|
||||
onClick={showPicker}
|
||||
title={t('editor.editorToolbar.emoji')}>
|
||||
title={t('editor.editorToolbar.emoji')}
|
||||
disabled={!changeEditorContent}>
|
||||
<ForkAwesomeIcon icon='smile-o' />
|
||||
</Button>
|
||||
</Fragment>
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
/*
|
||||
* 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 { EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared'
|
||||
|
||||
export const getEmojiIcon = (emoji: EmojiClickEventDetail): string => {
|
||||
if (emoji.unicode) {
|
||||
return emoji.unicode
|
||||
}
|
||||
if (emoji.name) {
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<i class="fa ${emoji.name}"></i>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getEmojiShortCode = (emoji: EmojiClickEventDetail): string | undefined => {
|
||||
/**
|
||||
* Extracts the first shortcode that is associated with a clicked emoji.
|
||||
*
|
||||
* @param emoji The click event data from the emoji picker
|
||||
* @return The found emoji short code
|
||||
*/
|
||||
export const extractEmojiShortCode = (emoji: EmojiClickEventDetail): string | undefined => {
|
||||
if (!emoji.emoji.shortcodes) {
|
||||
return undefined
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { addLink } from './add-link'
|
||||
import type { ContentEdits } from './changes'
|
||||
|
||||
describe('add link', () => {
|
||||
describe('without to-cursor', () => {
|
||||
it('inserts a link', () => {
|
||||
const actual = addLink('', { from: 0 }, '')
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 0,
|
||||
insert: '[](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 12 }])
|
||||
})
|
||||
|
||||
it('inserts a link into a line', () => {
|
||||
const actual = addLink('aa', { from: 1 }, '')
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 1,
|
||||
to: 1,
|
||||
insert: '[](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 1, to: 13 }])
|
||||
})
|
||||
|
||||
it('inserts a link with a prefix', () => {
|
||||
const actual = addLink('', { from: 0 }, 'prefix')
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 0,
|
||||
insert: 'prefix[](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 18 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a normal text selected', () => {
|
||||
it('wraps the selection', () => {
|
||||
const actual = addLink(
|
||||
'a',
|
||||
{
|
||||
from: 0,
|
||||
to: 1
|
||||
},
|
||||
''
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 1,
|
||||
insert: '[a](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 13 }])
|
||||
})
|
||||
|
||||
it('wraps the selection inside of a line', () => {
|
||||
const actual = addLink('aba', { from: 1, to: 2 }, '')
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 1,
|
||||
to: 2,
|
||||
insert: '[b](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 1, to: 14 }])
|
||||
})
|
||||
|
||||
it('wraps the selection with a prefix', () => {
|
||||
const actual = addLink('a', { from: 0, to: 1 }, 'prefix')
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 1,
|
||||
insert: 'prefix[a](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 19 }])
|
||||
})
|
||||
|
||||
it('wraps a multi line selection', () => {
|
||||
const actual = addLink('a\nb\nc', { from: 0, to: 5 }, '')
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 5,
|
||||
insert: '[a\nb\nc](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 17 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a url selected', () => {
|
||||
it('wraps the selection', () => {
|
||||
const actual = addLink(
|
||||
'https://google.com',
|
||||
{
|
||||
from: 0,
|
||||
to: 18
|
||||
},
|
||||
''
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 18,
|
||||
insert: '[](https://google.com)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 22 }])
|
||||
})
|
||||
|
||||
it('wraps the selection with a prefix', () => {
|
||||
const actual = addLink(
|
||||
'https://google.com',
|
||||
{
|
||||
from: 0,
|
||||
to: 18
|
||||
},
|
||||
'prefix'
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 18,
|
||||
insert: 'prefix[](https://google.com)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 28 }])
|
||||
})
|
||||
|
||||
it(`wraps a multi line selection not as link`, () => {
|
||||
const actual = addLink('a\nhttps://google.com\nc', { from: 0, to: 22 }, '')
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 22,
|
||||
insert: '[a\nhttps://google.com\nc](https://)'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 0, to: 34 }])
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { CursorSelection } from './types/cursor-selection'
|
||||
import type { ContentEdits } from './types/changes'
|
||||
|
||||
const beforeDescription = '['
|
||||
const afterDescriptionBeforeLink = ']('
|
||||
const defaultUrl = 'https://'
|
||||
const afterLink = ')'
|
||||
|
||||
/**
|
||||
* Creates a copy of the given markdown content lines but inserts a new link tag.
|
||||
*
|
||||
* @param markdownContent The content of the document to modify
|
||||
* @param selection If the selection has no to cursor then the tag will be inserted at this position.
|
||||
* If the selection has a to cursor then the selected text will be inserted into the description or the URL part.
|
||||
* @param prefix An optional prefix for the link
|
||||
* @return the modified copy of lines
|
||||
*/
|
||||
export const addLink = (
|
||||
markdownContent: string,
|
||||
selection: CursorSelection,
|
||||
prefix = ''
|
||||
): [ContentEdits, CursorSelection] => {
|
||||
const from = selection.from
|
||||
const to = selection.to ?? from
|
||||
const selectedText = markdownContent.slice(from, to)
|
||||
const link = buildLink(selectedText, prefix)
|
||||
const changes: ContentEdits = [
|
||||
{
|
||||
from: from,
|
||||
to: to,
|
||||
insert: link
|
||||
}
|
||||
]
|
||||
return [changes, { from, to: from + link.length }]
|
||||
}
|
||||
|
||||
const buildLink = (selectedText: string, prefix: string): string => {
|
||||
const linkRegex = /^(?:https?|mailto):/
|
||||
if (linkRegex.test(selectedText)) {
|
||||
return prefix + beforeDescription + afterDescriptionBeforeLink + selectedText + afterLink
|
||||
} else {
|
||||
return prefix + beforeDescription + selectedText + afterDescriptionBeforeLink + defaultUrl + afterLink
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { prependLinesOfSelection } from './prepend-lines-of-selection'
|
||||
import type { ContentEdits } from './types/changes'
|
||||
|
||||
describe('replace lines of selection', () => {
|
||||
it('replaces only the from-cursor line if no to-cursor is present', () => {
|
||||
const actual = prependLinesOfSelection(
|
||||
'a\nb\nc',
|
||||
{
|
||||
from: 2
|
||||
},
|
||||
(line, lineIndexInBlock) => `text_${lineIndexInBlock}_`
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: 'text_0_'
|
||||
}
|
||||
]
|
||||
expect(actual).toStrictEqual([expectedChanges, { from: 2, to: 10 }])
|
||||
})
|
||||
|
||||
it('inserts a line prepend if no content is there', () => {
|
||||
const actual = prependLinesOfSelection(
|
||||
'',
|
||||
{
|
||||
from: 0
|
||||
},
|
||||
(line, lineIndexInBlock) => `text_${lineIndexInBlock}_`
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 0,
|
||||
insert: 'text_0_'
|
||||
}
|
||||
]
|
||||
expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 7 }])
|
||||
})
|
||||
|
||||
it('replaces only one line if from-cursor and to-cursor are in the same line', () => {
|
||||
const actual = prependLinesOfSelection(
|
||||
'a\nb\nc',
|
||||
{
|
||||
from: 2,
|
||||
to: 2
|
||||
},
|
||||
(line, lineIndexInBlock) => `text_${lineIndexInBlock}_`
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: 'text_0_'
|
||||
}
|
||||
]
|
||||
expect(actual).toStrictEqual([expectedChanges, { from: 2, to: 10 }])
|
||||
})
|
||||
|
||||
it('replaces multiple lines', () => {
|
||||
const actual = prependLinesOfSelection(
|
||||
'a\nb\nc\nd\ne',
|
||||
{
|
||||
from: 2,
|
||||
to: 6
|
||||
},
|
||||
(line, lineIndexInBlock) => `${lineIndexInBlock} `
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: '0 '
|
||||
},
|
||||
{
|
||||
from: 4,
|
||||
to: 4,
|
||||
insert: '1 '
|
||||
},
|
||||
{
|
||||
from: 6,
|
||||
to: 6,
|
||||
insert: '2 '
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 2, to: 13 }])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { CursorSelection } from './types/cursor-selection'
|
||||
import { searchForEndOfLine, searchForStartOfLine } from './utils/change-cursors-to-whole-line-if-no-to-cursor'
|
||||
import type { ContentEdits } from './types/changes'
|
||||
|
||||
/**
|
||||
* Creates a copy of the given markdown content lines but modifies the whole selected lines.
|
||||
*
|
||||
* @param markdownContent The lines of the document to modify
|
||||
* @param selection If the selection has no to cursor then only the from line will be modified.
|
||||
* If the selection has a to cursor then all lines in the selection will be modified.
|
||||
* @param modifyLine A function that modifies the selected lines
|
||||
* @return the modified copy of lines
|
||||
*/
|
||||
export const prependLinesOfSelection = (
|
||||
markdownContent: string,
|
||||
selection: CursorSelection,
|
||||
modifyLine: (line: string, lineIndexInBlock: number) => string
|
||||
): [ContentEdits, CursorSelection] => {
|
||||
const toIndex = selection.to ?? selection.from
|
||||
let currentIndex = selection.from
|
||||
let indexInBlock = 0
|
||||
let newStartOfSelection = selection.from
|
||||
let newEndOfSelection = toIndex
|
||||
let lengthOfAddedPrefixes = 0
|
||||
const changes: ContentEdits = []
|
||||
while (currentIndex <= toIndex && currentIndex <= markdownContent.length) {
|
||||
const startOfLine = searchForStartOfLine(markdownContent, currentIndex)
|
||||
if (startOfLine < newStartOfSelection) {
|
||||
newStartOfSelection = startOfLine
|
||||
}
|
||||
const endOfLine = searchForEndOfLine(markdownContent, currentIndex)
|
||||
const line = markdownContent.slice(startOfLine, endOfLine)
|
||||
const linePrefix = modifyLine(line, indexInBlock)
|
||||
lengthOfAddedPrefixes += linePrefix.length
|
||||
indexInBlock += 1
|
||||
changes.push({
|
||||
from: startOfLine,
|
||||
to: startOfLine,
|
||||
insert: linePrefix
|
||||
})
|
||||
currentIndex = endOfLine + 1
|
||||
if (endOfLine + lengthOfAddedPrefixes > newEndOfSelection) {
|
||||
newEndOfSelection = endOfLine + lengthOfAddedPrefixes
|
||||
}
|
||||
}
|
||||
return [changes, { from: newStartOfSelection, to: newEndOfSelection }]
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ContentEdits } from './types/changes'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
export const replaceInContent = (currentContent: string, replaceable: string, replacement: string): ContentEdits => {
|
||||
return Optional.ofNullable(currentContent.indexOf(replaceable))
|
||||
.filter((index) => index > -1)
|
||||
.map((index) => [{ from: index, to: index + replaceable.length, insert: replacement }])
|
||||
.orElse([])
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { replaceSelection } from './replace-selection'
|
||||
import type { ContentEdits } from './changes'
|
||||
|
||||
describe('replace selection', () => {
|
||||
it('inserts a text after the from-cursor if no to-cursor is present', () => {
|
||||
const actual = replaceSelection(
|
||||
{
|
||||
from: 2
|
||||
},
|
||||
'text2'
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: 'text2'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }])
|
||||
})
|
||||
|
||||
it('inserts a text if from-cursor and to-cursor are the same', () => {
|
||||
const actual = replaceSelection(
|
||||
{
|
||||
from: 2,
|
||||
to: 2
|
||||
},
|
||||
'text2'
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: 'text2'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }])
|
||||
})
|
||||
|
||||
it('replaces a single line text', () => {
|
||||
const actual = replaceSelection(
|
||||
{
|
||||
from: 7,
|
||||
to: 8
|
||||
},
|
||||
'text4'
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 7,
|
||||
to: 8,
|
||||
insert: 'text4'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 7, to: 12 }])
|
||||
})
|
||||
|
||||
it('replaces a multi line text', () => {
|
||||
const actual = replaceSelection(
|
||||
{
|
||||
from: 2,
|
||||
to: 15
|
||||
},
|
||||
'text4'
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 2,
|
||||
to: 15,
|
||||
insert: 'text4'
|
||||
}
|
||||
]
|
||||
expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ContentEdits } from './types/changes'
|
||||
import type { CursorSelection } from './types/cursor-selection'
|
||||
|
||||
/**
|
||||
* Creates a new {@link NoteDetails note state} but replaces the selected text.
|
||||
*
|
||||
* @param selection If the selection has no to cursor then text will only be inserted.
|
||||
* If the selection has a to cursor then the selection will be replaced.
|
||||
* @param insertText The text that should be inserted
|
||||
* @return The modified state
|
||||
*/
|
||||
export const replaceSelection = (
|
||||
selection: CursorSelection,
|
||||
insertText: string,
|
||||
insertNewLine?: boolean
|
||||
): [ContentEdits, CursorSelection] => {
|
||||
const fromCursor = selection.from
|
||||
const toCursor = selection.to ?? selection.from
|
||||
|
||||
const changes: ContentEdits = [
|
||||
{
|
||||
from: fromCursor,
|
||||
to: toCursor,
|
||||
insert: (insertNewLine ? '\n' : '') + insertText
|
||||
}
|
||||
]
|
||||
return [changes, { from: fromCursor, to: insertText.length + fromCursor + (insertNewLine ? 1 : 0) }]
|
||||
}
|
13
src/components/editor-page/editor-pane/tool-bar/formatters/types/changes.d.ts
vendored
Normal file
13
src/components/editor-page/editor-pane/tool-bar/formatters/types/changes.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface ContentEdit {
|
||||
from: number
|
||||
to: number
|
||||
insert: string
|
||||
}
|
||||
|
||||
export type ContentEdits = ContentEdit[]
|
10
src/components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection.d.ts
vendored
Normal file
10
src/components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface CursorSelection {
|
||||
from: number
|
||||
to?: number
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {
|
||||
changeCursorsToWholeLineIfNoToCursor,
|
||||
searchForEndOfLine,
|
||||
searchForStartOfLine
|
||||
} from './change-cursors-to-whole-line-if-no-to-cursor'
|
||||
import type { CursorSelection } from '../types/cursor-selection'
|
||||
|
||||
describe('changeCursorsToWholeLineIfNoToCursor', () => {
|
||||
it(`returns the given selection if to cursor is present`, () => {
|
||||
const givenSelection = {
|
||||
from: 0,
|
||||
to: 0
|
||||
}
|
||||
|
||||
expect(changeCursorsToWholeLineIfNoToCursor('', givenSelection)).toEqual(givenSelection)
|
||||
})
|
||||
|
||||
it(`returns the corrected selection if cursor is in a line`, () => {
|
||||
const givenSelection = {
|
||||
from: 9
|
||||
}
|
||||
|
||||
const expectedSelection: CursorSelection = {
|
||||
from: 6,
|
||||
to: 14
|
||||
}
|
||||
|
||||
expect(changeCursorsToWholeLineIfNoToCursor(`I'm a\nfriendly\ntest string!`, givenSelection)).toEqual(
|
||||
expectedSelection
|
||||
)
|
||||
})
|
||||
|
||||
it(`returns the corrected selection if cursor is out of bounds`, () => {
|
||||
const givenSelection = {
|
||||
from: 123
|
||||
}
|
||||
|
||||
const expectedSelection: CursorSelection = {
|
||||
from: 0,
|
||||
to: 27
|
||||
}
|
||||
|
||||
expect(changeCursorsToWholeLineIfNoToCursor(`I'm a friendly test string!`, givenSelection)).toEqual(
|
||||
expectedSelection
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchForStartOfLine', () => {
|
||||
it('finds the start of the string', () => {
|
||||
expect(searchForStartOfLine('a', 1)).toBe(0)
|
||||
})
|
||||
it('finds the start of the string if the index is lower out of bounds', () => {
|
||||
expect(searchForStartOfLine('a', -100)).toBe(0)
|
||||
})
|
||||
it('finds the start of the string if the index is upper out of bounds', () => {
|
||||
expect(searchForStartOfLine('a', 100)).toBe(0)
|
||||
})
|
||||
it('finds the start of a line', () => {
|
||||
expect(searchForStartOfLine('a\nb', 3)).toBe(2)
|
||||
})
|
||||
it('finds the start of a line if the index is lower out of bounds', () => {
|
||||
expect(searchForStartOfLine('a\nb', -100)).toBe(0)
|
||||
})
|
||||
it('finds the start of a line if the index is upper out of bounds', () => {
|
||||
expect(searchForStartOfLine('a\nb', 100)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchForEndOfLine', () => {
|
||||
it('finds the end of the string', () => {
|
||||
expect(searchForEndOfLine('a', 1)).toBe(1)
|
||||
})
|
||||
it('finds the end of the string if the index is lower out of bounds', () => {
|
||||
expect(searchForEndOfLine('a', -100)).toBe(1)
|
||||
})
|
||||
it('finds the end of the string if the index is upper out of bounds', () => {
|
||||
expect(searchForEndOfLine('a', 100)).toBe(1)
|
||||
})
|
||||
it('finds the start of a line', () => {
|
||||
expect(searchForEndOfLine('a\nb', 2)).toBe(3)
|
||||
})
|
||||
it('finds the start of a line if the index is lower out of bounds', () => {
|
||||
expect(searchForEndOfLine('a\nb', -100)).toBe(1)
|
||||
})
|
||||
it('finds the start of a line if the index is upper out of bounds', () => {
|
||||
expect(searchForEndOfLine('a\nb', 100)).toBe(3)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { CursorSelection } from '../types/cursor-selection'
|
||||
|
||||
/**
|
||||
* If the given cursor selection has no to position then the selection will be changed to cover the whole line of the from cursor.
|
||||
*
|
||||
* @param markdownContent The markdown content that is used to calculate the start and end position of the line
|
||||
* @param selection The selection that is in the line whose start and end index should be calculated
|
||||
* @return The corrected selection if no to cursor is present or the unmodified selection otherwise
|
||||
* @throws Error if the line, that the from cursor is referring to, doesn't exist.
|
||||
*/
|
||||
export const changeCursorsToWholeLineIfNoToCursor = (
|
||||
markdownContent: string,
|
||||
selection: CursorSelection
|
||||
): CursorSelection => {
|
||||
if (selection.to !== undefined) {
|
||||
return selection
|
||||
}
|
||||
|
||||
const newFrom = searchForStartOfLine(markdownContent, selection.from)
|
||||
const newTo = searchForEndOfLine(markdownContent, selection.from)
|
||||
|
||||
return {
|
||||
from: newFrom,
|
||||
to: newTo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the position of the first character after the nearest
|
||||
* new line before the given start position.
|
||||
*
|
||||
* @param content The content that should be looked through
|
||||
* @param startPosition The position from which the search should start
|
||||
* @return The found new line character or the start of the content if no new line could be found
|
||||
*/
|
||||
export const searchForStartOfLine = (content: string, startPosition: number): number => {
|
||||
const adjustedStartPosition = Math.min(Math.max(0, startPosition), content.length)
|
||||
|
||||
for (let characterIndex = adjustedStartPosition; characterIndex > 0; characterIndex -= 1) {
|
||||
if (content.slice(characterIndex - 1, characterIndex) === '\n') {
|
||||
return characterIndex
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the position of the last character before the nearest
|
||||
* new line after the given start position.
|
||||
*
|
||||
* @param content The content that should be looked through
|
||||
* @param startPosition The position from which the search should start
|
||||
* @return The found new line character or the end of the content if no new line could be found
|
||||
*/
|
||||
export const searchForEndOfLine = (content: string, startPosition: number): number => {
|
||||
const adjustedStartPosition = Math.min(Math.max(0, startPosition), content.length)
|
||||
|
||||
for (let characterIndex = adjustedStartPosition; characterIndex < content.length; characterIndex += 1) {
|
||||
if (content.slice(characterIndex, characterIndex + 1) === '\n') {
|
||||
return characterIndex
|
||||
}
|
||||
}
|
||||
return content.length
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { wrapSelection } from './wrap-selection'
|
||||
import type { ContentEdits } from './types/changes'
|
||||
|
||||
describe('wrap selection', () => {
|
||||
it(`doesn't modify any line if no to-cursor is present`, () => {
|
||||
const actual = wrapSelection(
|
||||
{
|
||||
from: 0
|
||||
},
|
||||
'before',
|
||||
'after'
|
||||
)
|
||||
|
||||
expect(actual).toStrictEqual([[], { from: 0 }])
|
||||
})
|
||||
|
||||
it(`wraps the selected text in the same line`, () => {
|
||||
const actual = wrapSelection(
|
||||
{
|
||||
from: 0,
|
||||
to: 1
|
||||
},
|
||||
'before',
|
||||
'after'
|
||||
)
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 0,
|
||||
insert: 'before'
|
||||
},
|
||||
{
|
||||
from: 1,
|
||||
to: 1,
|
||||
insert: 'after'
|
||||
}
|
||||
]
|
||||
|
||||
expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 12 }])
|
||||
})
|
||||
|
||||
it(`wraps the selected text in different lines`, () => {
|
||||
const actual = wrapSelection(
|
||||
{
|
||||
from: 0,
|
||||
to: 5
|
||||
},
|
||||
'before',
|
||||
'after'
|
||||
)
|
||||
|
||||
const expectedChanges: ContentEdits = [
|
||||
{
|
||||
from: 0,
|
||||
to: 0,
|
||||
insert: 'before'
|
||||
},
|
||||
{
|
||||
from: 5,
|
||||
to: 5,
|
||||
insert: 'after'
|
||||
}
|
||||
]
|
||||
|
||||
expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 16 }])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ContentEdits } from './types/changes'
|
||||
import type { CursorSelection } from './types/cursor-selection'
|
||||
|
||||
/**
|
||||
* Creates a copy of the given markdown content lines but wraps the selection.
|
||||
*
|
||||
* @param selection If the selection has no to cursor then nothing will happen.
|
||||
* If the selection has a to cursor then the selected text will be wrapped.
|
||||
* @param symbolStart A text that will be inserted before the from cursor
|
||||
* @param symbolEnd A text that will be inserted after the to cursor
|
||||
* @return the modified copy of lines
|
||||
*/
|
||||
export const wrapSelection = (
|
||||
selection: CursorSelection,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): [ContentEdits, CursorSelection] => {
|
||||
if (selection.to === undefined) {
|
||||
return [[], selection]
|
||||
}
|
||||
|
||||
const to = selection.to
|
||||
const from = selection.from
|
||||
const changes: ContentEdits = [
|
||||
{
|
||||
from: from,
|
||||
to: from,
|
||||
insert: symbolStart
|
||||
},
|
||||
{
|
||||
from: to,
|
||||
to: to,
|
||||
insert: symbolEnd
|
||||
}
|
||||
]
|
||||
|
||||
return [changes, { from, to: to + symbolEnd.length + symbolStart.length }]
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createMarkdownTable } from './create-markdown-table'
|
||||
|
||||
describe('create markdown table', () => {
|
||||
it('generates a valid table', () => {
|
||||
expect(createMarkdownTable(5, 2)).toBe(`| # 1 | # 2 |
|
||||
| ---- | ---- |
|
||||
| | |
|
||||
| | |
|
||||
| | |
|
||||
| | |
|
||||
| | |`)
|
||||
})
|
||||
it('crashes if called with zero rows', () => {
|
||||
expect(() => createMarkdownTable(0, 1)).toThrow()
|
||||
})
|
||||
it('crashes if called with zero columns', () => {
|
||||
expect(() => createMarkdownTable(1, 0)).toThrow()
|
||||
})
|
||||
it('crashes if called with negative rows', () => {
|
||||
expect(() => createMarkdownTable(-1, 1)).toThrow()
|
||||
})
|
||||
it('crashes if called with negative columns', () => {
|
||||
expect(() => createMarkdownTable(1, -1)).toThrow()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createNumberRangeArray } from '../../../../common/number-range/number-range'
|
||||
|
||||
/**
|
||||
* Creates a Markdown table with the given size.
|
||||
*
|
||||
* @param rows The number of table rows
|
||||
* @param columns The number of table columns
|
||||
* @throws Error if an invalid table size was given
|
||||
* @return The created Markdown table
|
||||
*/
|
||||
export const createMarkdownTable = (rows: number, columns: number): string => {
|
||||
if (rows <= 0) {
|
||||
throw new Error(`Can't generate a table with ${rows} rows.`)
|
||||
} else if (columns <= 0) {
|
||||
throw new Error(`Can't generate a table with ${columns} columns.`)
|
||||
}
|
||||
const rowArray = createNumberRangeArray(rows)
|
||||
const colArray = createNumberRangeArray(columns).map((col) => col + 1)
|
||||
const head = '| # ' + colArray.join(' | # ') + ' |'
|
||||
const divider = '| ' + colArray.map(() => '----').join(' | ') + ' |'
|
||||
const body = rowArray.map(() => '| ' + colArray.map(() => ' ').join(' | ') + ' |').join('\n')
|
||||
return `${head}\n${divider}\n${body}`
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* 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
|
||||
*/
|
||||
|
@ -12,8 +12,9 @@ import { cypressId } from '../../../../../utils/cypress-attribute'
|
|||
import { TableSizePickerPopover } from './table-size-picker-popover'
|
||||
import { CustomTableSizeModal } from './custom-table-size-modal'
|
||||
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
|
||||
import { ShowIf } from '../../../../common/show-if/show-if'
|
||||
import { addTableAtCursor } from '../../../../../redux/note-details/methods'
|
||||
import { replaceSelection } from '../formatters/replace-selection'
|
||||
import { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback'
|
||||
import { createMarkdownTable } from './create-markdown-table'
|
||||
|
||||
enum PickerMode {
|
||||
INVISIBLE,
|
||||
|
@ -29,23 +30,22 @@ export const TablePickerButton: React.FC = () => {
|
|||
const [pickerMode, setPickerMode] = useState<PickerMode>(PickerMode.INVISIBLE)
|
||||
const onDismiss = useCallback(() => setPickerMode(PickerMode.INVISIBLE), [])
|
||||
const onShowModal = useCallback(() => setPickerMode(PickerMode.CUSTOM), [])
|
||||
const changeEditorContent = useChangeEditorContentCallback()
|
||||
|
||||
const onSizeSelect = useCallback((rows: number, columns: number) => {
|
||||
addTableAtCursor(rows, columns)
|
||||
setPickerMode(PickerMode.INVISIBLE)
|
||||
}, [])
|
||||
const onSizeSelect = useCallback(
|
||||
(rows: number, columns: number) => {
|
||||
const table = createMarkdownTable(rows, columns)
|
||||
changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, table, true))
|
||||
setPickerMode(PickerMode.INVISIBLE)
|
||||
},
|
||||
[changeEditorContent]
|
||||
)
|
||||
|
||||
const tableTitle = useMemo(() => t('editor.editorToolbar.table.titleWithoutSize'), [t])
|
||||
|
||||
const button = useRef(null)
|
||||
|
||||
const toggleOverlayVisibility = useCallback(
|
||||
() =>
|
||||
setPickerMode((oldPickerMode) =>
|
||||
oldPickerMode === PickerMode.INVISIBLE ? PickerMode.GRID : PickerMode.INVISIBLE
|
||||
),
|
||||
[]
|
||||
)
|
||||
const toggleOverlayVisibility = useCallback(() => {
|
||||
setPickerMode((oldPickerMode) => (oldPickerMode === PickerMode.INVISIBLE ? PickerMode.GRID : PickerMode.INVISIBLE))
|
||||
}, [])
|
||||
|
||||
const onOverlayHide = useCallback(() => {
|
||||
setPickerMode((oldMode) => {
|
||||
|
@ -76,7 +76,8 @@ export const TablePickerButton: React.FC = () => {
|
|||
variant='light'
|
||||
onClick={toggleOverlayVisibility}
|
||||
title={tableTitle}
|
||||
ref={button}>
|
||||
ref={button}
|
||||
disabled={!changeEditorContent}>
|
||||
<ForkAwesomeIcon icon='table' />
|
||||
</Button>
|
||||
<Overlay
|
||||
|
@ -84,16 +85,14 @@ export const TablePickerButton: React.FC = () => {
|
|||
onHide={onOverlayHide}
|
||||
show={pickerMode === PickerMode.GRID}
|
||||
placement={'bottom'}
|
||||
rootClose={true}>
|
||||
rootClose={pickerMode === PickerMode.GRID}>
|
||||
{createPopoverElement}
|
||||
</Overlay>
|
||||
<ShowIf condition={pickerMode === PickerMode.CUSTOM}>
|
||||
<CustomTableSizeModal
|
||||
showModal={pickerMode === PickerMode.CUSTOM}
|
||||
onDismiss={onDismiss}
|
||||
onSizeSelect={onSizeSelect}
|
||||
/>
|
||||
</ShowIf>
|
||||
<CustomTableSizeModal
|
||||
showModal={pickerMode === PickerMode.CUSTOM}
|
||||
onDismiss={onDismiss}
|
||||
onSizeSelect={onSizeSelect}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* 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
|
||||
*/
|
||||
|
@ -8,9 +8,25 @@ import React, { Fragment, Suspense } from 'react'
|
|||
import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
|
||||
import { TablePickerButton } from './table-picker/table-picker-button'
|
||||
import styles from './tool-bar.module.scss'
|
||||
import { UploadImageButton } from './upload-image-button'
|
||||
import { ToolbarButton } from './toolbar-button'
|
||||
import { FormatType } from '../../../../redux/note-details/types'
|
||||
import { UploadImageButton } from './upload-image-button/upload-image-button'
|
||||
import { BoldButton } from './buttons/bold-button'
|
||||
import { ItalicButton } from './buttons/italic-button'
|
||||
import { UnderlineButton } from './buttons/underline-button'
|
||||
import { StrikethroughButton } from './buttons/strikethrough-button'
|
||||
import { SubscriptButton } from './buttons/subscript-button'
|
||||
import { SuperscriptButton } from './buttons/superscript-button'
|
||||
import { HighlightButton } from './buttons/highlight-button'
|
||||
import { HeaderLevelButton } from './buttons/header-level-button'
|
||||
import { CodeFenceButton } from './buttons/code-fence-button'
|
||||
import { QuotesButton } from './buttons/quotes-button'
|
||||
import { UnorderedListButton } from './buttons/unordered-list-button'
|
||||
import { OrderedListButton } from './buttons/ordered-list-button'
|
||||
import { CheckListButton } from './buttons/check-list-button'
|
||||
import { LinkButton } from './buttons/link-button'
|
||||
import { ImageLinkButton } from './buttons/image-link-button'
|
||||
import { HorizontalLineButton } from './buttons/horizontal-line-button'
|
||||
import { CollapsibleBlockButton } from './buttons/collapsible-block-button'
|
||||
import { CommentButton } from './buttons/comment-button'
|
||||
|
||||
const EmojiPickerButton = React.lazy(() => import('./emoji-picker/emoji-picker-button'))
|
||||
|
||||
|
@ -18,32 +34,32 @@ export const ToolBar: React.FC = () => {
|
|||
return (
|
||||
<ButtonToolbar className={`bg-light ${styles.toolbar}`}>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<ToolbarButton icon={'bold'} formatType={FormatType.BOLD} />
|
||||
<ToolbarButton icon={'italic'} formatType={FormatType.ITALIC} />
|
||||
<ToolbarButton icon={'underline'} formatType={FormatType.UNDERLINE} />
|
||||
<ToolbarButton icon={'strikethrough'} formatType={FormatType.STRIKETHROUGH} />
|
||||
<ToolbarButton icon={'subscript'} formatType={FormatType.SUBSCRIPT} />
|
||||
<ToolbarButton icon={'superscript'} formatType={FormatType.SUPERSCRIPT} />
|
||||
<ToolbarButton icon={'eraser'} formatType={FormatType.HIGHLIGHT} />
|
||||
<BoldButton />
|
||||
<ItalicButton />
|
||||
<UnderlineButton />
|
||||
<StrikethroughButton />
|
||||
<SubscriptButton />
|
||||
<SuperscriptButton />
|
||||
<HighlightButton />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<ToolbarButton icon={'header'} formatType={FormatType.HEADER_LEVEL} />
|
||||
<ToolbarButton icon={'code'} formatType={FormatType.CODE_FENCE} />
|
||||
<ToolbarButton icon={'quote-right'} formatType={FormatType.QUOTES} />
|
||||
<ToolbarButton icon={'list'} formatType={FormatType.UNORDERED_LIST} />
|
||||
<ToolbarButton icon={'list-ol'} formatType={FormatType.ORDERED_LIST} />
|
||||
<ToolbarButton icon={'check-square'} formatType={FormatType.CHECK_LIST} />
|
||||
<HeaderLevelButton />
|
||||
<CodeFenceButton />
|
||||
<QuotesButton />
|
||||
<UnorderedListButton />
|
||||
<OrderedListButton />
|
||||
<CheckListButton />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<ToolbarButton icon={'link'} formatType={FormatType.LINK} />
|
||||
<ToolbarButton icon={'picture-o'} formatType={FormatType.IMAGE_LINK} />
|
||||
<LinkButton />
|
||||
<ImageLinkButton />
|
||||
<UploadImageButton />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<TablePickerButton />
|
||||
<ToolbarButton icon={'minus'} formatType={FormatType.HORIZONTAL_LINE} />
|
||||
<ToolbarButton icon={'caret-square-o-down'} formatType={FormatType.COLLAPSIBLE_BLOCK} />
|
||||
<ToolbarButton icon={'comment'} formatType={FormatType.COMMENT} />
|
||||
<HorizontalLineButton />
|
||||
<CollapsibleBlockButton />
|
||||
<CommentButton />
|
||||
<Suspense fallback={<Fragment />}>
|
||||
<EmojiPickerButton />
|
||||
</Suspense>
|
||||
|
|
|
@ -8,28 +8,41 @@ import React, { useCallback, useMemo } from 'react'
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import type { FormatType } from '../../../../redux/note-details/types'
|
||||
import type { IconName } from '../../../common/fork-awesome/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatSelection } from '../../../../redux/note-details/methods'
|
||||
import { useChangeEditorContentCallback } from '../../change-content-context/use-change-editor-content-callback'
|
||||
import type { ContentFormatter } from '../../change-content-context/change-content-context'
|
||||
|
||||
export interface ToolbarButtonProps {
|
||||
icon: IconName
|
||||
formatType: FormatType
|
||||
i18nKey: string
|
||||
iconName: IconName
|
||||
formatter: ContentFormatter
|
||||
}
|
||||
|
||||
export const ToolbarButton: React.FC<ToolbarButtonProps> = ({ formatType, icon }) => {
|
||||
/**
|
||||
* Renders a button for the editor toolbar that formats the content using a given formatter function.
|
||||
*
|
||||
* @param i18nKey Used to generate a title for the button by interpreting it as translation key in the i18n-namespace `editor.editorToolbar`-
|
||||
* @param iconName A fork awesome icon name that is shown in the button
|
||||
* @param formatter The formatter function changes the editor content on click
|
||||
*/
|
||||
export const ToolbarButton: React.FC<ToolbarButtonProps> = ({ i18nKey, iconName, formatter }) => {
|
||||
const { t } = useTranslation('', { keyPrefix: 'editor.editorToolbar' })
|
||||
const changeEditorContent = useChangeEditorContentCallback()
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
formatSelection(formatType)
|
||||
}, [formatType])
|
||||
|
||||
const title = useMemo(() => t(formatType), [formatType, t])
|
||||
changeEditorContent?.(formatter)
|
||||
}, [formatter, changeEditorContent])
|
||||
const title = useMemo(() => t(i18nKey), [i18nKey, t])
|
||||
|
||||
return (
|
||||
<Button variant='light' onClick={onClick} title={title} {...cypressId('toolbar.' + formatType)}>
|
||||
<ForkAwesomeIcon icon={icon} />
|
||||
<Button
|
||||
variant='light'
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={!changeEditorContent}
|
||||
{...cypressId('toolbar.' + i18nKey)}>
|
||||
<ForkAwesomeIcon icon={iconName} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment, useCallback, useRef } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { UploadInput } from '../../sidebar/upload-input'
|
||||
import { handleUpload } from '../upload-handler'
|
||||
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
|
||||
export const UploadImageButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const clickRef = useRef<() => void>()
|
||||
const buttonClick = useCallback(() => {
|
||||
clickRef.current?.()
|
||||
}, [])
|
||||
|
||||
const onUploadImage = useCallback((file: File) => {
|
||||
handleUpload(file)
|
||||
return Promise.resolve()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button
|
||||
variant='light'
|
||||
onClick={buttonClick}
|
||||
title={t('editor.editorToolbar.uploadImage')}
|
||||
{...cypressId('editor-toolbar-upload-image-button')}>
|
||||
<ForkAwesomeIcon icon={'upload'} />
|
||||
</Button>
|
||||
<UploadInput
|
||||
onLoad={onUploadImage}
|
||||
acceptedFiles={acceptedMimeTypes}
|
||||
onClickRef={clickRef}
|
||||
{...cypressId('editor-toolbar-upload-image-input')}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { EditorState, SelectionRange } from '@codemirror/state'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import { extractSelectedText } from './extract-selected-text'
|
||||
|
||||
describe('extract selected text', () => {
|
||||
const mockContent = "I'm a mock content!"
|
||||
|
||||
const mockState = (selection: SelectionRange | undefined): EditorState => {
|
||||
return Mock.of<EditorState>({
|
||||
selection: {
|
||||
main: selection
|
||||
},
|
||||
sliceDoc: (from, to) => mockContent.slice(from, to)
|
||||
})
|
||||
}
|
||||
|
||||
it('extracts the correct text', () => {
|
||||
const selection = Mock.of<SelectionRange>({
|
||||
from: 2,
|
||||
to: 5
|
||||
})
|
||||
const state = mockState(selection)
|
||||
expect(extractSelectedText(state)).toBe('m a')
|
||||
})
|
||||
|
||||
it("doesn't extract if from and to are the same", () => {
|
||||
const selection = Mock.of<SelectionRange>({
|
||||
from: 2,
|
||||
to: 2
|
||||
})
|
||||
const state = mockState(selection)
|
||||
expect(extractSelectedText(state)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("doesn't extract if there is no selection", () => {
|
||||
const state = mockState(undefined)
|
||||
expect(extractSelectedText(state)).toBeUndefined()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
/**
|
||||
* Extracts the currently selected text from the given CodeMirror state.
|
||||
*
|
||||
* @param state The CodeMirror state that provides the content and the selection
|
||||
* @return The selected text or {@code undefined} if no text was selected
|
||||
*/
|
||||
export const extractSelectedText = (state: EditorState): string | undefined => {
|
||||
return Optional.ofNullable(state.selection.main)
|
||||
.map((selection) => [selection.from, selection.to])
|
||||
.filter(([from, to]) => from !== to)
|
||||
.map<string | undefined>(([from, to]) => state.sliceDoc(from, to))
|
||||
.orElse(undefined)
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment, useCallback, useRef } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { UploadInput } from '../../../sidebar/upload-input'
|
||||
import { acceptedMimeTypes } from '../../../../common/upload-image-mimetypes'
|
||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||
import { useHandleUpload } from '../../hooks/use-handle-upload'
|
||||
import { ShowIf } from '../../../../common/show-if/show-if'
|
||||
import { useCodeMirrorReference } from '../../../change-content-context/change-content-context'
|
||||
import { extractSelectedText } from './extract-selected-text'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
/**
|
||||
* Shows a button that uploads a chosen file to the backend and adds the link to the note.
|
||||
*/
|
||||
export const UploadImageButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const clickRef = useRef<() => void>()
|
||||
const buttonClick = useCallback(() => {
|
||||
clickRef.current?.()
|
||||
}, [])
|
||||
|
||||
const handleUpload = useHandleUpload()
|
||||
const codeMirror = useCodeMirrorReference()
|
||||
|
||||
const onUploadImage = useCallback(
|
||||
(file: File) => {
|
||||
const description = Optional.ofNullable(codeMirror?.state)
|
||||
.map<string | undefined>((state) => extractSelectedText(state))
|
||||
.orElse(undefined)
|
||||
handleUpload(file, undefined, description)
|
||||
return Promise.resolve()
|
||||
},
|
||||
[codeMirror, handleUpload]
|
||||
)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button
|
||||
variant='light'
|
||||
onClick={buttonClick}
|
||||
disabled={!codeMirror}
|
||||
title={t('editor.editorToolbar.uploadImage')}
|
||||
{...cypressId('editor-toolbar-upload-image-button')}>
|
||||
<ForkAwesomeIcon icon={'upload'} />
|
||||
</Button>
|
||||
<ShowIf condition={!!codeMirror}>
|
||||
<UploadInput
|
||||
onLoad={onUploadImage}
|
||||
acceptedFiles={acceptedMimeTypes}
|
||||
onClickRef={clickRef}
|
||||
{...cypressId('editor-toolbar-upload-image-input')}
|
||||
/>
|
||||
</ShowIf>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ApplicationState } from '../../../../../redux/application-state'
|
||||
import { initialState } from '../../../../../redux/note-details/initial-state'
|
||||
import { isCursorInCodeFence } from './codefenceDetection'
|
||||
import * as storeModule from '../../../../../redux'
|
||||
import { Mock } from 'ts-mockery'
|
||||
|
||||
describe('Check whether cursor is in codefence', () => {
|
||||
const getGlobalStateMocked = jest.spyOn(storeModule, 'getGlobalState')
|
||||
|
||||
const mockRedux = (content: string, from: number): void => {
|
||||
const contentLines = content.split('\n')
|
||||
getGlobalStateMocked.mockImplementation(() =>
|
||||
Mock.from<ApplicationState>({
|
||||
noteDetails: {
|
||||
...initialState,
|
||||
selection: {
|
||||
from
|
||||
},
|
||||
markdownContent: {
|
||||
plain: content,
|
||||
lines: contentLines
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns false for empty document', () => {
|
||||
mockRedux('', 0)
|
||||
expect(isCursorInCodeFence()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true with one open codefence directly above', () => {
|
||||
mockRedux('```\n', 4)
|
||||
expect(isCursorInCodeFence()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true with one open codefence and empty lines above', () => {
|
||||
mockRedux('```\n\n\n', 5)
|
||||
expect(isCursorInCodeFence()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false with one completed codefence above', () => {
|
||||
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', 13)
|
||||
expect(isCursorInCodeFence()).toBe(true)
|
||||
})
|
||||
})
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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 noteDetails = getGlobalState().noteDetails
|
||||
const lines = noteDetails.markdownContent.plain.slice(0, noteDetails.selection.from).split('\n')
|
||||
return countCodeFenceLinesUntilIndex(lines) % 2 === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the lines that start or end a code fence.
|
||||
*
|
||||
* @param lines The lines that should be inspected
|
||||
* @return the counted lines
|
||||
*/
|
||||
const countCodeFenceLinesUntilIndex = (lines: string[]): number => {
|
||||
return lines.filter((line) => line.startsWith('```')).length
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { convertClipboardTableToMarkdown, isTable } from '../../table-extractor'
|
||||
import { handleUpload } from '../../upload-handler'
|
||||
import { replaceSelection } from '../../../../../redux/note-details/methods'
|
||||
import { isCursorInCodeFence } from './codefenceDetection'
|
||||
import { getGlobalState } from '../../../../../redux'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
type ClipboardDataFormats = 'text' | 'url' | 'text/plain' | 'text/uri-list' | 'text/html'
|
||||
|
||||
export interface PasteEvent {
|
||||
clipboardData: {
|
||||
files: FileList
|
||||
getData: (format: ClipboardDataFormats) => string
|
||||
}
|
||||
preventDefault: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 clipboardData The {@link DataTransfer} from the paste event
|
||||
* @return {@code true} if the event was processed. {@code false} otherwise
|
||||
*/
|
||||
export const handleTablePaste = (clipboardData: DataTransfer): boolean => {
|
||||
if (!getGlobalState().editorConfig.smartPaste || isCursorInCodeFence()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Optional.ofNullable(clipboardData.getData('text'))
|
||||
.filter(isTable)
|
||||
.map(convertClipboardTableToMarkdown)
|
||||
.map((markdownTable) => {
|
||||
replaceSelection(markdownTable)
|
||||
return true
|
||||
})
|
||||
.orElse(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given {@link PasteEvent paste event} contains files and uploads them.
|
||||
*
|
||||
* @param clipboardData The {@link DataTransfer} from the paste event
|
||||
* @return {@code true} if the event was processed. {@code false} otherwise
|
||||
*/
|
||||
export const handleFilePaste = (clipboardData: DataTransfer): boolean => {
|
||||
return Optional.of(clipboardData.files)
|
||||
.filter((files) => files.length > 0)
|
||||
.map((files) => {
|
||||
handleUpload(files[0])
|
||||
return true
|
||||
})
|
||||
.orElse(false)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue