mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-16 08:04:45 -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
|
@ -28,6 +28,7 @@ describe('File upload', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
it('via button', () => {
|
it('via button', () => {
|
||||||
|
cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true')
|
||||||
cy.getByCypressId('editor-toolbar-upload-image-button').should('be.visible')
|
cy.getByCypressId('editor-toolbar-upload-image-button').should('be.visible')
|
||||||
cy.getByCypressId('editor-toolbar-upload-image-input').selectFile(
|
cy.getByCypressId('editor-toolbar-upload-image-input').selectFile(
|
||||||
{
|
{
|
||||||
|
@ -37,15 +38,16 @@ describe('File upload', () => {
|
||||||
},
|
},
|
||||||
{ force: true }
|
{ force: true }
|
||||||
)
|
)
|
||||||
cy.get('.cm-line').contains(``)
|
cy.get('.cm-line').contains(``)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('via paste', () => {
|
it('via paste', () => {
|
||||||
|
cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true')
|
||||||
cy.fixture('demo.png').then((image: string) => {
|
cy.fixture('demo.png').then((image: string) => {
|
||||||
const pasteEvent = {
|
const pasteEvent = {
|
||||||
clipboardData: {
|
clipboardData: {
|
||||||
files: [Cypress.Blob.base64StringToBlob(image, 'image/png')],
|
files: [Cypress.Blob.base64StringToBlob(image, 'image/png')],
|
||||||
getData: (_: string) => ''
|
getData: () => ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cy.get('.cm-content').trigger('paste', pasteEvent)
|
cy.get('.cm-content').trigger('paste', pasteEvent)
|
||||||
|
@ -54,6 +56,7 @@ describe('File upload', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('via drag and drop', () => {
|
it('via drag and drop', () => {
|
||||||
|
cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true')
|
||||||
cy.get('.cm-content').selectFile(
|
cy.get('.cm-content').selectFile(
|
||||||
{
|
{
|
||||||
contents: '@demoImage',
|
contents: '@demoImage',
|
||||||
|
@ -62,11 +65,12 @@ describe('File upload', () => {
|
||||||
},
|
},
|
||||||
{ action: 'drag-drop', force: true }
|
{ action: 'drag-drop', force: true }
|
||||||
)
|
)
|
||||||
cy.get('.cm-line').contains(``)
|
cy.get('.cm-line').contains(``)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fails', () => {
|
it('fails', () => {
|
||||||
|
cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true')
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -89,12 +93,16 @@ describe('File upload', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lets text paste still work', () => {
|
it('lets text paste still work', () => {
|
||||||
|
cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true')
|
||||||
const testText = 'a long test text'
|
const testText = 'a long test text'
|
||||||
const pasteEvent = {
|
|
||||||
|
const pasteEvent: Event = Object.assign(new Event('paste', { bubbles: true, cancelable: true }), {
|
||||||
clipboardData: {
|
clipboardData: {
|
||||||
getData: (type = 'text') => testText
|
files: [],
|
||||||
}
|
getData: () => testText
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
cy.get('.cm-content').trigger('paste', pasteEvent)
|
cy.get('.cm-content').trigger('paste', pasteEvent)
|
||||||
cy.get('.cm-line').contains(`${testText}`)
|
cy.get('.cm-line').contains(`${testText}`)
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
import React, { createContext, useContext, useState } from 'react'
|
||||||
|
import Optional from 'optional-js'
|
||||||
|
import type { EditorView } from '@codemirror/view'
|
||||||
|
import type { ContentEdits } from '../editor-pane/tool-bar/formatters/types/changes'
|
||||||
|
import type { CursorSelection } from '../editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
|
export type CodeMirrorReference = EditorView | undefined
|
||||||
|
type SetCodeMirrorReference = (value: CodeMirrorReference) => void
|
||||||
|
|
||||||
|
export type ContentFormatter = (parameters: {
|
||||||
|
currentSelection: CursorSelection
|
||||||
|
markdownContent: string
|
||||||
|
}) => [ContentEdits, CursorSelection | undefined]
|
||||||
|
|
||||||
|
type ChangeEditorContentContext = [CodeMirrorReference, SetCodeMirrorReference]
|
||||||
|
|
||||||
|
const changeEditorContentContext = createContext<ChangeEditorContentContext | undefined>(undefined)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the code mirror reference from the parent context
|
||||||
|
*/
|
||||||
|
export const useCodeMirrorReference = (): CodeMirrorReference => {
|
||||||
|
const contextContent = Optional.ofNullable(useContext(changeEditorContentContext)).orElseThrow(
|
||||||
|
() => new Error('No change content received. Did you forget to use the provider component')
|
||||||
|
)
|
||||||
|
return contextContent[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the code mirror reference from the parent context
|
||||||
|
*/
|
||||||
|
export const useSetCodeMirrorReference = (): SetCodeMirrorReference => {
|
||||||
|
const contextContent = Optional.ofNullable(useContext(changeEditorContentContext)).orElseThrow(
|
||||||
|
() => new Error('No change content received. Did you forget to use the provider component')
|
||||||
|
)
|
||||||
|
return contextContent[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a context for the child components that contains a ref to the current code mirror instance and a callback that posts changes to this codemirror.
|
||||||
|
*/
|
||||||
|
export const ChangeEditorContentContextProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||||
|
const [codeMirrorRef, setCodeMirrorRef] = useState<CodeMirrorReference>(undefined)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<changeEditorContentContext.Provider value={[codeMirrorRef, setCodeMirrorRef]}>
|
||||||
|
{children}
|
||||||
|
</changeEditorContentContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
10
src/components/editor-page/change-content-context/code-mirror-selection.d.ts
vendored
Normal file
10
src/components/editor-page/change-content-context/code-mirror-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 CodeMirrorSelection {
|
||||||
|
anchor: number
|
||||||
|
head?: number
|
||||||
|
}
|
|
@ -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 Optional from 'optional-js'
|
||||||
|
import type { CodeMirrorSelection } from './code-mirror-selection'
|
||||||
|
import type { ContentFormatter } from './change-content-context'
|
||||||
|
import { useCodeMirrorReference } from './change-content-context'
|
||||||
|
import type { CursorSelection } from '../editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
import type { EditorView } from '@codemirror/view'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the content of the given CodeMirror view using the given formatter function.
|
||||||
|
*
|
||||||
|
* @param view The CodeMirror view whose content should be changed
|
||||||
|
* @param formatter A function that generates changes that get dispatched to CodeMirror
|
||||||
|
*/
|
||||||
|
export const changeEditorContent = (view: EditorView, formatter: ContentFormatter): void => {
|
||||||
|
const [changes, selection] = formatter({
|
||||||
|
currentSelection: {
|
||||||
|
from: view.state.selection.main.from,
|
||||||
|
to: view.state.selection.main.to
|
||||||
|
},
|
||||||
|
markdownContent: view.state.doc.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
view.dispatch({ changes: changes, selection: convertSelectionToCodeMirrorSelection(selection) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a {@link ContentFormatter formatter function} that is linked to the current CodeMirror-View
|
||||||
|
* @see changeEditorContent
|
||||||
|
*/
|
||||||
|
export const useChangeEditorContentCallback = () => {
|
||||||
|
const codeMirrorRef = useCodeMirrorReference()
|
||||||
|
return useMemo(() => {
|
||||||
|
if (codeMirrorRef) {
|
||||||
|
return (callback: ContentFormatter) => changeEditorContent(codeMirrorRef, callback)
|
||||||
|
}
|
||||||
|
}, [codeMirrorRef])
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertSelectionToCodeMirrorSelection = (selection: CursorSelection | undefined) => {
|
||||||
|
return Optional.ofNullable(selection)
|
||||||
|
.map<CodeMirrorSelection | undefined>((selection) => ({ anchor: selection.from, head: selection.to }))
|
||||||
|
.orElse(undefined)
|
||||||
|
}
|
|
@ -9,8 +9,15 @@ import type { RenderIframeProps } from '../renderer-pane/render-iframe'
|
||||||
import { RenderIframe } from '../renderer-pane/render-iframe'
|
import { RenderIframe } from '../renderer-pane/render-iframe'
|
||||||
import { useSendFrontmatterInfoFromReduxToRenderer } from '../renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
|
import { useSendFrontmatterInfoFromReduxToRenderer } from '../renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
|
||||||
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
|
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
|
||||||
|
import { NoteType } from '../../../redux/note-details/types/note-details'
|
||||||
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
|
import { RendererType } from '../../render-page/window-post-message-communicator/rendering-message'
|
||||||
|
import { useSetCheckboxInEditor } from './hooks/use-set-checkbox-in-editor'
|
||||||
|
|
||||||
export type EditorDocumentRendererProps = Omit<RenderIframeProps, 'markdownContentLines'>
|
export type EditorDocumentRendererProps = Omit<
|
||||||
|
RenderIframeProps,
|
||||||
|
'markdownContentLines' | 'rendererType' | 'onTaskCheckedChange'
|
||||||
|
>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the markdown content from the global application state with the iframe renderer.
|
* Renders the markdown content from the global application state with the iframe renderer.
|
||||||
|
@ -20,6 +27,15 @@ export type EditorDocumentRendererProps = Omit<RenderIframeProps, 'markdownConte
|
||||||
export const EditorDocumentRenderer: React.FC<EditorDocumentRendererProps> = (props) => {
|
export const EditorDocumentRenderer: React.FC<EditorDocumentRendererProps> = (props) => {
|
||||||
useSendFrontmatterInfoFromReduxToRenderer()
|
useSendFrontmatterInfoFromReduxToRenderer()
|
||||||
const trimmedContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
|
const trimmedContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
|
||||||
|
const noteType: NoteType = useApplicationState((state) => state.noteDetails.frontmatter.type)
|
||||||
|
const setCheckboxInEditor = useSetCheckboxInEditor()
|
||||||
|
|
||||||
return <RenderIframe {...props} markdownContentLines={trimmedContentLines} />
|
return (
|
||||||
|
<RenderIframe
|
||||||
|
{...props}
|
||||||
|
onTaskCheckedChange={setCheckboxInEditor}
|
||||||
|
rendererType={noteType === NoteType.SLIDE ? RendererType.SLIDESHOW : RendererType.DOCUMENT}
|
||||||
|
markdownContentLines={trimmedContentLines}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useChangeEditorContentCallback } from '../../change-content-context/use-change-editor-content-callback'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import type { ContentEdits } from '../../editor-pane/tool-bar/formatters/types/changes'
|
||||||
|
import Optional from 'optional-js'
|
||||||
|
|
||||||
|
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]?])/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a callback that changes the state of a checkbox in a given line in the current codemirror instance.
|
||||||
|
*/
|
||||||
|
export const useSetCheckboxInEditor = () => {
|
||||||
|
const changeEditorContent = useChangeEditorContentCallback()
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(changedLineIndex: number, checkboxChecked: boolean): void => {
|
||||||
|
changeEditorContent?.(({ markdownContent }) => {
|
||||||
|
const lines = markdownContent.split('\n')
|
||||||
|
const lineStartIndex = findStartIndexOfLine(lines, changedLineIndex)
|
||||||
|
const edits = Optional.ofNullable(TASK_REGEX.exec(lines[changedLineIndex]))
|
||||||
|
.map<ContentEdits>(([, beforeCheckbox, oldCheckbox]) => {
|
||||||
|
const checkboxStartIndex = lineStartIndex + beforeCheckbox.length
|
||||||
|
return createCheckboxContentEdit(checkboxStartIndex, oldCheckbox, checkboxChecked)
|
||||||
|
})
|
||||||
|
.orElse([])
|
||||||
|
return [edits, undefined]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[changeEditorContent]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the start position of the wanted line index if the given lines would be concat with new-line-characters.
|
||||||
|
*
|
||||||
|
* @param lines The lines to search through
|
||||||
|
* @param wantedLineIndex The index of the line whose start position should be found
|
||||||
|
* @return the found start position
|
||||||
|
*/
|
||||||
|
const findStartIndexOfLine = (lines: string[], wantedLineIndex: number): number => {
|
||||||
|
return lines
|
||||||
|
.map((value) => value.length)
|
||||||
|
.filter((value, index) => index < wantedLineIndex)
|
||||||
|
.reduce((state, lineLength) => state + lineLength + 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link ContentEdits content edit} for the change of a checkbox at a given position.
|
||||||
|
*
|
||||||
|
* @param checkboxStartIndex The start index of the checkbox
|
||||||
|
* @param oldCheckbox The old checkbox that should be replaced
|
||||||
|
* @param newCheckboxState The new status of the checkbox
|
||||||
|
* @return the created {@link ContentEdits edit}
|
||||||
|
*/
|
||||||
|
const createCheckboxContentEdit = (
|
||||||
|
checkboxStartIndex: number,
|
||||||
|
oldCheckbox: string,
|
||||||
|
newCheckboxState: boolean
|
||||||
|
): ContentEdits => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
from: checkboxStartIndex,
|
||||||
|
to: checkboxStartIndex + oldCheckbox.length,
|
||||||
|
insert: `[${newCheckboxState ? 'x' : ' '}]`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -4,10 +4,10 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
|
import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
|
||||||
import { setCheckboxInMarkdownContent, updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
|
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
|
||||||
import { MotdModal } from '../common/motd-modal/motd-modal'
|
import { MotdModal } from '../common/motd-modal/motd-modal'
|
||||||
import { AppBar, AppBarMode } from './app-bar/app-bar'
|
import { AppBar, AppBarMode } from './app-bar/app-bar'
|
||||||
import { EditorMode } from './app-bar/editor-view-mode'
|
import { EditorMode } from './app-bar/editor-view-mode'
|
||||||
|
@ -15,17 +15,16 @@ import { useViewModeShortcuts } from './hooks/useViewModeShortcuts'
|
||||||
import { Sidebar } from './sidebar/sidebar'
|
import { Sidebar } from './sidebar/sidebar'
|
||||||
import { Splitter } from './splitter/splitter'
|
import { Splitter } from './splitter/splitter'
|
||||||
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
|
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
|
||||||
import { RendererType } from '../render-page/window-post-message-communicator/rendering-message'
|
|
||||||
import { useEditorModeFromUrl } from './hooks/useEditorModeFromUrl'
|
import { useEditorModeFromUrl } from './hooks/useEditorModeFromUrl'
|
||||||
import { UiNotifications } from '../notifications/ui-notifications'
|
import { UiNotifications } from '../notifications/ui-notifications'
|
||||||
import { useUpdateLocalHistoryEntry } from './hooks/useUpdateLocalHistoryEntry'
|
import { useUpdateLocalHistoryEntry } from './hooks/useUpdateLocalHistoryEntry'
|
||||||
import { useApplicationState } from '../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../hooks/common/use-application-state'
|
||||||
import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer'
|
import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer'
|
||||||
import { Logger } from '../../utils/logger'
|
import { Logger } from '../../utils/logger'
|
||||||
import { NoteType } from '../../redux/note-details/types/note-details'
|
|
||||||
import { NoteAndAppTitleHead } from '../layout/note-and-app-title-head'
|
import { NoteAndAppTitleHead } from '../layout/note-and-app-title-head'
|
||||||
import equal from 'fast-deep-equal'
|
import equal from 'fast-deep-equal'
|
||||||
import { EditorPane } from './editor-pane/editor-pane'
|
import { EditorPane } from './editor-pane/editor-pane'
|
||||||
|
import { ChangeEditorContentContextProvider } from './change-content-context/change-content-context'
|
||||||
|
|
||||||
export enum ScrollSource {
|
export enum ScrollSource {
|
||||||
EDITOR = 'editor',
|
EDITOR = 'editor',
|
||||||
|
@ -112,7 +111,6 @@ export const EditorPageContent: React.FC = () => {
|
||||||
),
|
),
|
||||||
[onEditorScroll, scrollState.editorScrollState, setEditorToScrollSource]
|
[onEditorScroll, scrollState.editorScrollState, setEditorToScrollSource]
|
||||||
)
|
)
|
||||||
const noteType: NoteType = useApplicationState((state) => state.noteDetails.frontmatter.type)
|
|
||||||
|
|
||||||
const rightPane = useMemo(
|
const rightPane = useMemo(
|
||||||
() => (
|
() => (
|
||||||
|
@ -120,17 +118,15 @@ export const EditorPageContent: React.FC = () => {
|
||||||
frameClasses={'h-100 w-100'}
|
frameClasses={'h-100 w-100'}
|
||||||
onMakeScrollSource={setRendererToScrollSource}
|
onMakeScrollSource={setRendererToScrollSource}
|
||||||
onFirstHeadingChange={updateNoteTitleByFirstHeading}
|
onFirstHeadingChange={updateNoteTitleByFirstHeading}
|
||||||
onTaskCheckedChange={setCheckboxInMarkdownContent}
|
|
||||||
onScroll={onMarkdownRendererScroll}
|
onScroll={onMarkdownRendererScroll}
|
||||||
scrollState={scrollState.rendererScrollState}
|
scrollState={scrollState.rendererScrollState}
|
||||||
rendererType={noteType === NoteType.SLIDE ? RendererType.SLIDESHOW : RendererType.DOCUMENT}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[noteType, onMarkdownRendererScroll, scrollState.rendererScrollState, setRendererToScrollSource]
|
[onMarkdownRendererScroll, scrollState.rendererScrollState, setRendererToScrollSource]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<ChangeEditorContentContextProvider>
|
||||||
<NoteAndAppTitleHead />
|
<NoteAndAppTitleHead />
|
||||||
<UiNotifications />
|
<UiNotifications />
|
||||||
<MotdModal />
|
<MotdModal />
|
||||||
|
@ -147,6 +143,6 @@ export const EditorPageContent: React.FC = () => {
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</ChangeEditorContentContextProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useRef } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
||||||
import { StatusBar } from './status-bar/status-bar'
|
import { StatusBar } from './status-bar/status-bar'
|
||||||
import { ToolBar } from './tool-bar/tool-bar'
|
import { ToolBar } from './tool-bar/tool-bar'
|
||||||
|
@ -12,54 +12,50 @@ import { useApplicationState } from '../../../hooks/common/use-application-state
|
||||||
import { setNoteContent } from '../../../redux/note-details/methods'
|
import { setNoteContent } from '../../../redux/note-details/methods'
|
||||||
import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content'
|
import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content'
|
||||||
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||||
import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer'
|
|
||||||
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror'
|
|
||||||
import ReactCodeMirror 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 { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
||||||
import styles from './extended-codemirror/codemirror.module.scss'
|
import styles from './extended-codemirror/codemirror.module.scss'
|
||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Logger } from '../../../utils/logger'
|
|
||||||
import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension'
|
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 { useCodeMirrorFileInsertExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-insert-extension'
|
||||||
import { useCodeMirrorFileDropExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-drop-extension'
|
|
||||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { autocompletion } from '@codemirror/autocomplete'
|
import { autocompletion } from '@codemirror/autocomplete'
|
||||||
import { useCodeMirrorFocusReference } from './hooks/use-code-mirror-focus-reference'
|
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||||
import { useOffScreenScrollProtection } from './hooks/use-off-screen-scroll-protection'
|
|
||||||
import { cypressId } from '../../../utils/cypress-attribute'
|
|
||||||
import { findLanguageByCodeBlockName } from '../../markdown-renderer/markdown-extension/code-block-markdown-extension/find-language-by-code-block-name'
|
import { findLanguageByCodeBlockName } from '../../markdown-renderer/markdown-extension/code-block-markdown-extension/find-language-by-code-block-name'
|
||||||
import { languages } from '@codemirror/language-data'
|
import { languages } from '@codemirror/language-data'
|
||||||
|
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
|
||||||
const logger = new Logger('EditorPane')
|
import { useCodeMirrorReference, useSetCodeMirrorReference } from '../change-content-context/change-content-context'
|
||||||
|
import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension'
|
||||||
|
import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer'
|
||||||
|
|
||||||
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
||||||
const markdownContent = useNoteMarkdownContent()
|
const markdownContent = useNoteMarkdownContent()
|
||||||
|
|
||||||
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
||||||
const codeMirrorRef = useRef<ReactCodeMirrorRef | null>(null)
|
|
||||||
|
|
||||||
useApplyScrollState(codeMirrorRef, scrollState)
|
useApplyScrollState(scrollState)
|
||||||
|
|
||||||
const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
|
const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
|
||||||
const editorPasteExtension = useCodeMirrorPasteExtension()
|
const tablePasteExtensions = useCodeMirrorTablePasteExtension()
|
||||||
const dropExtension = useCodeMirrorFileDropExtension()
|
const fileInsertExtension = useCodeMirrorFileInsertExtension()
|
||||||
const [focusExtension, editorFocused] = useCodeMirrorFocusReference()
|
const cursorActivityExtension = useCursorActivityCallback()
|
||||||
const saveOffFocusScrollStateExtensions = useOffScreenScrollProtection()
|
|
||||||
const cursorActivityExtension = useCursorActivityCallback(editorFocused)
|
|
||||||
|
|
||||||
const onBeforeChange = useCallback(
|
const onBeforeChange = useCallback((value: string): void => {
|
||||||
(value: string): void => {
|
|
||||||
if (!editorFocused.current) {
|
|
||||||
logger.debug("Don't post content change because editor isn't focused")
|
|
||||||
} else {
|
|
||||||
setNoteContent(value)
|
setNoteContent(value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const codeMirrorRef = useCodeMirrorReference()
|
||||||
|
const setCodeMirrorReference = useSetCodeMirrorReference()
|
||||||
|
|
||||||
|
const updateViewContext = useMemo(() => {
|
||||||
|
return EditorView.updateListener.of((update) => {
|
||||||
|
if (codeMirrorRef !== update.view) {
|
||||||
|
setCodeMirrorReference(update.view)
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
[editorFocused]
|
}, [codeMirrorRef, setCodeMirrorReference])
|
||||||
)
|
|
||||||
|
|
||||||
const extensions = useMemo(
|
const extensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
@ -67,23 +63,15 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
||||||
base: markdownLanguage,
|
base: markdownLanguage,
|
||||||
codeLanguages: (input) => findLanguageByCodeBlockName(languages, input)
|
codeLanguages: (input) => findLanguageByCodeBlockName(languages, input)
|
||||||
}),
|
}),
|
||||||
...saveOffFocusScrollStateExtensions,
|
|
||||||
focusExtension,
|
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
editorScrollExtension,
|
editorScrollExtension,
|
||||||
editorPasteExtension,
|
tablePasteExtensions,
|
||||||
dropExtension,
|
fileInsertExtension,
|
||||||
autocompletion(),
|
autocompletion(),
|
||||||
cursorActivityExtension
|
|
||||||
],
|
|
||||||
[
|
|
||||||
cursorActivityExtension,
|
cursorActivityExtension,
|
||||||
dropExtension,
|
updateViewContext
|
||||||
editorPasteExtension,
|
],
|
||||||
editorScrollExtension,
|
[cursorActivityExtension, fileInsertExtension, tablePasteExtensions, editorScrollExtension, updateViewContext]
|
||||||
focusExtension,
|
|
||||||
saveOffFocusScrollStateExtensions
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
useOnImageUploadFromRenderer()
|
useOnImageUploadFromRenderer()
|
||||||
|
@ -100,7 +88,8 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
||||||
className={`d-flex flex-column h-100 position-relative`}
|
className={`d-flex flex-column h-100 position-relative`}
|
||||||
onTouchStart={onMakeScrollSource}
|
onTouchStart={onMakeScrollSource}
|
||||||
onMouseEnter={onMakeScrollSource}
|
onMouseEnter={onMakeScrollSource}
|
||||||
{...cypressId('editor-pane')}>
|
{...cypressId('editor-pane')}
|
||||||
|
{...cypressAttribute('editor-ready', String(codeMirrorRef !== undefined))}>
|
||||||
<MaxLengthWarning />
|
<MaxLengthWarning />
|
||||||
<ToolBar />
|
<ToolBar />
|
||||||
<ReactCodeMirror
|
<ReactCodeMirror
|
||||||
|
@ -115,7 +104,6 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
||||||
theme={oneDark}
|
theme={oneDark}
|
||||||
value={markdownContent}
|
value={markdownContent}
|
||||||
onChange={onBeforeChange}
|
onChange={onBeforeChange}
|
||||||
ref={codeMirrorRef}
|
|
||||||
/>
|
/>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
* 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]
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import type { Extension } from '@codemirror/state'
|
||||||
|
import { handleUpload } from '../use-handle-upload'
|
||||||
|
import Optional from 'optional-js'
|
||||||
|
import type { CursorSelection } from '../../tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
|
const calculateCursorPositionInEditor = (view: EditorView, event: MouseEvent): number => {
|
||||||
|
return Optional.ofNullable(event.pageX)
|
||||||
|
.flatMap((posX) => {
|
||||||
|
return Optional.ofNullable(event.pageY).map((posY) => {
|
||||||
|
return view.posAtCoords({ x: posX, y: posY })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.orElse(view.state.selection.main.head)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processFileList = (view: EditorView, fileList?: FileList, cursorSelection?: CursorSelection): boolean => {
|
||||||
|
return Optional.ofNullable(fileList)
|
||||||
|
.filter((files) => files.length > 0)
|
||||||
|
.map((files) => {
|
||||||
|
handleUpload(view, files[0], cursorSelection)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.orElse(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a callback that is used to process file drops and pastes on the code mirror editor
|
||||||
|
*
|
||||||
|
* @return the code mirror callback
|
||||||
|
*/
|
||||||
|
export const useCodeMirrorFileInsertExtension = (): Extension => {
|
||||||
|
return useMemo(() => {
|
||||||
|
return EditorView.domEventHandlers({
|
||||||
|
drop: (event, view) => {
|
||||||
|
processFileList(view, event.dataTransfer?.files, { from: calculateCursorPositionInEditor(view, event) }) &&
|
||||||
|
event.preventDefault()
|
||||||
|
},
|
||||||
|
paste: (event, view) => {
|
||||||
|
processFileList(view, event.clipboardData?.files) && event.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
}
|
|
@ -1,34 +0,0 @@
|
||||||
/*
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
|
@ -1,19 +1,19 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEditorReceiveHandler } from '../../../render-page/window-post-message-communicator/hooks/use-editor-receive-handler'
|
import { useEditorReceiveHandler } from '../../../../render-page/window-post-message-communicator/hooks/use-editor-receive-handler'
|
||||||
import type { ImageUploadMessage } from '../../../render-page/window-post-message-communicator/rendering-message'
|
import type { ImageUploadMessage } from '../../../../render-page/window-post-message-communicator/rendering-message'
|
||||||
import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message'
|
import { CommunicationMessageType } from '../../../../render-page/window-post-message-communicator/rendering-message'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { getGlobalState } from '../../../../redux'
|
import { getGlobalState } from '../../../../../redux'
|
||||||
import { handleUpload } from '../upload-handler'
|
import { Logger } from '../../../../../utils/logger'
|
||||||
import { Logger } from '../../../../utils/logger'
|
import { findRegexMatchInText } from './find-regex-match-in-text'
|
||||||
import { findRegexMatchInText } from '../find-regex-match-in-text'
|
|
||||||
import Optional from 'optional-js'
|
import Optional from 'optional-js'
|
||||||
import type { CursorSelection } from '../../../../redux/editor/types'
|
import { useHandleUpload } from '../use-handle-upload'
|
||||||
|
import type { CursorSelection } from '../../tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
const log = new Logger('useOnImageUpload')
|
const log = new Logger('useOnImageUpload')
|
||||||
const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
|
const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
|
||||||
|
@ -22,9 +22,12 @@ const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
|
||||||
* Receives {@link CommunicationMessageType.IMAGE_UPLOAD image upload events} via iframe communication and processes the attached uploads.
|
* Receives {@link CommunicationMessageType.IMAGE_UPLOAD image upload events} via iframe communication and processes the attached uploads.
|
||||||
*/
|
*/
|
||||||
export const useOnImageUploadFromRenderer = (): void => {
|
export const useOnImageUploadFromRenderer = (): void => {
|
||||||
|
const handleUpload = useHandleUpload()
|
||||||
|
|
||||||
useEditorReceiveHandler(
|
useEditorReceiveHandler(
|
||||||
CommunicationMessageType.IMAGE_UPLOAD,
|
CommunicationMessageType.IMAGE_UPLOAD,
|
||||||
useCallback((values: ImageUploadMessage) => {
|
useCallback(
|
||||||
|
(values: ImageUploadMessage) => {
|
||||||
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
|
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
|
||||||
if (!dataUri.startsWith('data:image/')) {
|
if (!dataUri.startsWith('data:image/')) {
|
||||||
log.error('Received uri is no data uri and image!')
|
log.error('Received uri is no data uri and image!')
|
||||||
|
@ -41,7 +44,9 @@ export const useOnImageUploadFromRenderer = (): void => {
|
||||||
handleUpload(file, cursorSelection, alt, title)
|
handleUpload(file, cursorSelection, alt, title)
|
||||||
})
|
})
|
||||||
.catch((error) => log.error(error))
|
.catch((error) => log.error(error))
|
||||||
}, [])
|
},
|
||||||
|
[handleUpload]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isCursorInCodeFence } from './codefenceDetection'
|
||||||
|
|
||||||
|
describe('Check whether cursor is in codefence', () => {
|
||||||
|
it('returns false for empty document', () => {
|
||||||
|
expect(isCursorInCodeFence('', 0)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true with one open codefence directly above', () => {
|
||||||
|
expect(isCursorInCodeFence('```\n', 4)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true with one open codefence and empty lines above', () => {
|
||||||
|
expect(isCursorInCodeFence('```\n\n\n', 5)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false with one completed codefence above', () => {
|
||||||
|
expect(isCursorInCodeFence('```\n\n```\n', 8)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true with one completed and one open codefence above', () => {
|
||||||
|
expect(isCursorInCodeFence('```\n\n```\n\n```\n\n', 13)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given cursor position is in a code fence.
|
||||||
|
*
|
||||||
|
* @param markdownContent The markdown content whose content should be checked
|
||||||
|
* @param cursorPosition The cursor position that may or may not be in a code fence
|
||||||
|
* @return {@code true} if the given cursor position is in a code fence
|
||||||
|
*/
|
||||||
|
export const isCursorInCodeFence = (markdownContent: string, cursorPosition: number): boolean => {
|
||||||
|
const lines = markdownContent.slice(0, cursorPosition).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,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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
|
@ -1,11 +1,15 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createNumberRangeArray } from '../../common/number-range/number-range'
|
import { createNumberRangeArray } from '../../../../common/number-range/number-range'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given text is a tab-and-new-line-separated table.
|
||||||
|
* @param text The text to check
|
||||||
|
*/
|
||||||
export const isTable = (text: string): boolean => {
|
export const isTable = (text: string): boolean => {
|
||||||
// Tables must consist of multiple rows and columns
|
// Tables must consist of multiple rows and columns
|
||||||
if (!text.includes('\n') || !text.includes('\t')) {
|
if (!text.includes('\n') || !text.includes('\t')) {
|
||||||
|
@ -27,6 +31,11 @@ export const isTable = (text: string): boolean => {
|
||||||
return tabsPerLines.every((line) => line === tabsPerLines[0])
|
return tabsPerLines.every((line) => line === tabsPerLines[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reformat the given text as Markdown table
|
||||||
|
* @param pasteData The plain text table separated by tabs and new-lines
|
||||||
|
* @return the formatted Markdown table
|
||||||
|
*/
|
||||||
export const convertClipboardTableToMarkdown = (pasteData: string): string => {
|
export const convertClipboardTableToMarkdown = (pasteData: string): string => {
|
||||||
if (pasteData.trim() === '') {
|
if (pasteData.trim() === '') {
|
||||||
return ''
|
return ''
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import type { Extension } from '@codemirror/state'
|
||||||
|
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||||
|
import { changeEditorContent } from '../../../change-content-context/use-change-editor-content-callback'
|
||||||
|
import Optional from 'optional-js'
|
||||||
|
import { replaceSelection } from '../../tool-bar/formatters/replace-selection'
|
||||||
|
import { convertClipboardTableToMarkdown, isTable } from './table-extractor'
|
||||||
|
import { isCursorInCodeFence } from './codefenceDetection'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link Extension code mirror extension} that handles the smart table detection on paste-from-clipboard events.
|
||||||
|
*
|
||||||
|
* @return the created {@link Extension code mirror extension}
|
||||||
|
*/
|
||||||
|
export const useCodeMirrorTablePasteExtension = (): Extension[] => {
|
||||||
|
const smartPaste = useApplicationState((state) => state.editorConfig.smartPaste)
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return smartPaste
|
||||||
|
? [
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
paste: (event, view) => {
|
||||||
|
if (isCursorInCodeFence(view.state.doc.toString(), view.state.selection.main.from)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Optional.ofNullable(event.clipboardData)
|
||||||
|
.map((clipboardData) => clipboardData.getData('text'))
|
||||||
|
.filter(isTable)
|
||||||
|
.map(convertClipboardTableToMarkdown)
|
||||||
|
.ifPresent((markdownTable) => {
|
||||||
|
changeEditorContent(view, ({ currentSelection }) => replaceSelection(currentSelection, markdownTable))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}, [smartPaste])
|
||||||
|
}
|
|
@ -1,15 +1,14 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MutableRefObject } from 'react'
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import type { ScrollState } from '../../synced-scroll/scroll-props'
|
import type { ScrollState } from '../../synced-scroll/scroll-props'
|
||||||
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror'
|
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import equal from 'fast-deep-equal'
|
import equal from 'fast-deep-equal'
|
||||||
|
import { useCodeMirrorReference } from '../../change-content-context/change-content-context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}.
|
* Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}.
|
||||||
|
@ -31,14 +30,12 @@ export const applyScrollState = (view: EditorView, scrollState: ScrollState): vo
|
||||||
* @param editorRef The editor that should be manipulated
|
* @param editorRef The editor that should be manipulated
|
||||||
* @param scrollState The scroll state that should be monitored
|
* @param scrollState The scroll state that should be monitored
|
||||||
*/
|
*/
|
||||||
export const useApplyScrollState = (
|
export const useApplyScrollState = (scrollState?: ScrollState): void => {
|
||||||
editorRef: MutableRefObject<ReactCodeMirrorRef | null>,
|
|
||||||
scrollState?: ScrollState
|
|
||||||
): void => {
|
|
||||||
const lastScrollPosition = useRef<ScrollState>()
|
const lastScrollPosition = useRef<ScrollState>()
|
||||||
|
const codeMirrorRef = useCodeMirrorReference()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const view = editorRef.current?.view
|
const view = codeMirrorRef
|
||||||
if (!view || !scrollState) {
|
if (!view || !scrollState) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -48,5 +45,5 @@ export const useApplyScrollState = (
|
||||||
}
|
}
|
||||||
applyScrollState(view, scrollState)
|
applyScrollState(view, scrollState)
|
||||||
lastScrollPosition.current = scrollState
|
lastScrollPosition.current = scrollState
|
||||||
}, [editorRef, scrollState])
|
}, [codeMirrorRef, scrollState])
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
/*
|
|
||||||
* 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]
|
|
||||||
}
|
|
|
@ -4,22 +4,18 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RefObject } from 'react'
|
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { updateCursorPositions } from '../../../../redux/note-details/methods'
|
import { updateCursorPositions } from '../../../../redux/note-details/methods'
|
||||||
import type { ViewUpdate } from '@codemirror/view'
|
import type { ViewUpdate } from '@codemirror/view'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { Logger } from '../../../../utils/logger'
|
|
||||||
import type { Extension, SelectionRange } from '@codemirror/state'
|
import type { Extension, SelectionRange } from '@codemirror/state'
|
||||||
|
|
||||||
const logger = new Logger('useCursorActivityCallback')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a callback for codemirror that handles cursor changes
|
* Provides a callback for codemirror that handles cursor changes
|
||||||
*
|
*
|
||||||
* @return the generated callback
|
* @return the generated callback
|
||||||
*/
|
*/
|
||||||
export const useCursorActivityCallback = (editorFocused: RefObject<boolean>): Extension => {
|
export const useCursorActivityCallback = (): Extension => {
|
||||||
const lastMainSelection = useRef<SelectionRange>()
|
const lastMainSelection = useRef<SelectionRange>()
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
|
@ -30,16 +26,11 @@ export const useCursorActivityCallback = (editorFocused: RefObject<boolean>): Ex
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastMainSelection.current = firstSelection
|
lastMainSelection.current = firstSelection
|
||||||
if (!editorFocused.current) {
|
updateCursorPositions({
|
||||||
logger.debug("Don't post updated cursor because editor isn't focused")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const newCursorPos = {
|
|
||||||
from: firstSelection.from,
|
from: firstSelection.from,
|
||||||
to: firstSelection.to === firstSelection.from ? undefined : firstSelection.to
|
to: firstSelection.to === firstSelection.from ? undefined : firstSelection.to
|
||||||
}
|
})
|
||||||
updateCursorPositions(newCursorPos)
|
|
||||||
}),
|
}),
|
||||||
[editorFocused]
|
[]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { uploadFile } from '../../../../api/media'
|
||||||
|
import { getGlobalState } from '../../../../redux'
|
||||||
|
import { supportedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||||
|
import { t } from 'i18next'
|
||||||
|
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { changeEditorContent } from '../../change-content-context/use-change-editor-content-callback'
|
||||||
|
import { replaceSelection } from '../tool-bar/formatters/replace-selection'
|
||||||
|
import { replaceInContent } from '../tool-bar/formatters/replace-in-content'
|
||||||
|
import type { CursorSelection } from '../tool-bar/formatters/types/cursor-selection'
|
||||||
|
import type { EditorView } from '@codemirror/view'
|
||||||
|
import type { ContentFormatter } from '../../change-content-context/change-content-context'
|
||||||
|
import { useCodeMirrorReference } from '../../change-content-context/change-content-context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the upload of the given file and inserts the correct Markdown code
|
||||||
|
*
|
||||||
|
* @param view the codemirror instance that is used to insert the Markdown code
|
||||||
|
* @param file The file to upload
|
||||||
|
* @param cursorSelection The position where the progress message should be placed
|
||||||
|
* @param description The text that should be used in the description part of the resulting image tag
|
||||||
|
* @param additionalUrlText Additional text that should be inserted behind the link but within the tag
|
||||||
|
*/
|
||||||
|
export const handleUpload = (
|
||||||
|
view: EditorView,
|
||||||
|
file: File,
|
||||||
|
cursorSelection?: CursorSelection,
|
||||||
|
description?: string,
|
||||||
|
additionalUrlText?: string
|
||||||
|
): void => {
|
||||||
|
const changeContent = (callback: ContentFormatter) => changeEditorContent(view, callback)
|
||||||
|
if (!file || !supportedMimeTypes.includes(file.type) || !changeContent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const randomId = Math.random().toString(36).slice(7)
|
||||||
|
const uploadFileInfo = description
|
||||||
|
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description })
|
||||||
|
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
|
||||||
|
|
||||||
|
const uploadPlaceholder = ``
|
||||||
|
const noteId = getGlobalState().noteDetails.id
|
||||||
|
changeContent(({ currentSelection }) => {
|
||||||
|
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false)
|
||||||
|
})
|
||||||
|
uploadFile(noteId, file)
|
||||||
|
.then(({ url }) => {
|
||||||
|
const replacement = ``
|
||||||
|
changeContent(({ markdownContent }) => [
|
||||||
|
replaceInContent(markdownContent, uploadPlaceholder, replacement),
|
||||||
|
undefined
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
|
||||||
|
const replacement = `![upload of ${file.name} failed]()`
|
||||||
|
changeContent(({ markdownContent }) => [
|
||||||
|
replaceInContent(markdownContent, uploadPlaceholder, replacement),
|
||||||
|
undefined
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a callback that uploads the given file and writes the progress into the given editor at the given cursor positions.
|
||||||
|
*
|
||||||
|
* @return The generated callback
|
||||||
|
*/
|
||||||
|
export const useHandleUpload = (): ((
|
||||||
|
file: File,
|
||||||
|
cursorSelection?: CursorSelection,
|
||||||
|
description?: string,
|
||||||
|
additionalUrlText?: string
|
||||||
|
) => void) => {
|
||||||
|
const codeMirrorReference = useCodeMirrorReference()
|
||||||
|
return useCallback(
|
||||||
|
(file: File, cursorSelection?: CursorSelection, description?: string, additionalUrlText?: string): void => {
|
||||||
|
if (codeMirrorReference) {
|
||||||
|
handleUpload(codeMirrorReference, file, cursorSelection, description, additionalUrlText)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[codeMirrorReference]
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,59 +0,0 @@
|
||||||
/*
|
|
||||||
* 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]
|
|
||||||
}, [])
|
|
||||||
}
|
|
|
@ -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
|
* 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 { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { EmojiPicker } from './emoji-picker'
|
import { EmojiPicker } from './emoji-picker'
|
||||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
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 type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
||||||
import Optional from 'optional-js'
|
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 = () => {
|
export const EmojiPickerButton: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
const onEmojiSelected = useCallback((emoji: EmojiClickEventDetail) => {
|
const changeEditorContent = useChangeEditorContentCallback()
|
||||||
|
|
||||||
|
const onEmojiSelected = useCallback(
|
||||||
|
(emojiClickEvent: EmojiClickEventDetail) => {
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
Optional.ofNullable(getEmojiShortCode(emoji)).ifPresent((shortCode) => replaceSelection(shortCode))
|
Optional.ofNullable(extractEmojiShortCode(emojiClickEvent)).ifPresent((shortCode) => {
|
||||||
}, [])
|
changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, shortCode, false))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[changeEditorContent]
|
||||||
|
)
|
||||||
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
|
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
|
||||||
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
|
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
|
||||||
|
|
||||||
|
@ -32,7 +40,8 @@ export const EmojiPickerButton: React.FC = () => {
|
||||||
{...cypressId('show-emoji-picker')}
|
{...cypressId('show-emoji-picker')}
|
||||||
variant='light'
|
variant='light'
|
||||||
onClick={showPicker}
|
onClick={showPicker}
|
||||||
title={t('editor.editorToolbar.emoji')}>
|
title={t('editor.editorToolbar.emoji')}
|
||||||
|
disabled={!changeEditorContent}>
|
||||||
<ForkAwesomeIcon icon='smile-o' />
|
<ForkAwesomeIcon icon='smile-o' />
|
||||||
</Button>
|
</Button>
|
||||||
</Fragment>
|
</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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared'
|
import type { EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared'
|
||||||
|
|
||||||
export const getEmojiIcon = (emoji: EmojiClickEventDetail): string => {
|
/**
|
||||||
if (emoji.unicode) {
|
* Extracts the first shortcode that is associated with a clicked emoji.
|
||||||
return emoji.unicode
|
*
|
||||||
}
|
* @param emoji The click event data from the emoji picker
|
||||||
if (emoji.name) {
|
* @return The found emoji short code
|
||||||
// noinspection CheckTagEmptyBody
|
*/
|
||||||
return `<i class="fa ${emoji.name}"></i>`
|
export const extractEmojiShortCode = (emoji: EmojiClickEventDetail): string | undefined => {
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getEmojiShortCode = (emoji: EmojiClickEventDetail): string | undefined => {
|
|
||||||
if (!emoji.emoji.shortcodes) {
|
if (!emoji.emoji.shortcodes) {
|
||||||
return undefined
|
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 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -4,8 +4,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stringSplice } from './utils/string-splice'
|
import type { CursorSelection } from './types/cursor-selection'
|
||||||
import type { CursorSelection } from '../../../editor/types'
|
import type { ContentEdits } from './types/changes'
|
||||||
|
|
||||||
const beforeDescription = '['
|
const beforeDescription = '['
|
||||||
const afterDescriptionBeforeLink = ']('
|
const afterDescriptionBeforeLink = ']('
|
||||||
|
@ -25,13 +25,19 @@ export const addLink = (
|
||||||
markdownContent: string,
|
markdownContent: string,
|
||||||
selection: CursorSelection,
|
selection: CursorSelection,
|
||||||
prefix = ''
|
prefix = ''
|
||||||
): [string, CursorSelection] => {
|
): [ContentEdits, CursorSelection] => {
|
||||||
const from = selection.from
|
const from = selection.from
|
||||||
const to = selection.to ?? from
|
const to = selection.to ?? from
|
||||||
const selectedText = markdownContent.slice(from, to)
|
const selectedText = markdownContent.slice(from, to)
|
||||||
const link = buildLink(selectedText, prefix)
|
const link = buildLink(selectedText, prefix)
|
||||||
const newContent = stringSplice(markdownContent, selection.from, link, selectedText.length)
|
const changes: ContentEdits = [
|
||||||
return [newContent, { from, to: from + link.length }]
|
{
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
insert: link
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return [changes, { from, to: from + link.length }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildLink = (selectedText: string, prefix: string): string => {
|
const buildLink = (selectedText: string, prefix: string): string => {
|
|
@ -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([])
|
||||||
|
}
|
|
@ -5,52 +5,77 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { replaceSelection } from './replace-selection'
|
import { replaceSelection } from './replace-selection'
|
||||||
|
import type { ContentEdits } from './changes'
|
||||||
|
|
||||||
describe('replace selection', () => {
|
describe('replace selection', () => {
|
||||||
it('inserts a text after the from-cursor if no to-cursor is present', () => {
|
it('inserts a text after the from-cursor if no to-cursor is present', () => {
|
||||||
const actual = replaceSelection(
|
const actual = replaceSelection(
|
||||||
'text1',
|
|
||||||
{
|
{
|
||||||
from: 2
|
from: 2
|
||||||
},
|
},
|
||||||
'text2'
|
'text2'
|
||||||
)
|
)
|
||||||
expect(actual).toEqual(['tetext2xt1', { from: 2, to: 7 }])
|
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', () => {
|
it('inserts a text if from-cursor and to-cursor are the same', () => {
|
||||||
const actual = replaceSelection(
|
const actual = replaceSelection(
|
||||||
'text1',
|
|
||||||
{
|
{
|
||||||
from: 2,
|
from: 2,
|
||||||
to: 2
|
to: 2
|
||||||
},
|
},
|
||||||
'text2'
|
'text2'
|
||||||
)
|
)
|
||||||
expect(actual).toEqual(['tetext2xt1', { from: 2, to: 7 }])
|
const expectedChanges: ContentEdits = [
|
||||||
|
{
|
||||||
|
from: 2,
|
||||||
|
to: 2,
|
||||||
|
insert: 'text2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replaces a single line text', () => {
|
it('replaces a single line text', () => {
|
||||||
const actual = replaceSelection(
|
const actual = replaceSelection(
|
||||||
'text1\ntext2\ntext3',
|
|
||||||
{
|
{
|
||||||
from: 7,
|
from: 7,
|
||||||
to: 8
|
to: 8
|
||||||
},
|
},
|
||||||
'text4'
|
'text4'
|
||||||
)
|
)
|
||||||
expect(actual).toEqual(['text1\nttext4xt2\ntext3', { from: 7, to: 12 }])
|
const expectedChanges: ContentEdits = [
|
||||||
|
{
|
||||||
|
from: 7,
|
||||||
|
to: 8,
|
||||||
|
insert: 'text4'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(actual).toEqual([expectedChanges, { from: 7, to: 12 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replaces a multi line text', () => {
|
it('replaces a multi line text', () => {
|
||||||
const actual = replaceSelection(
|
const actual = replaceSelection(
|
||||||
'text1\ntext2\ntext3',
|
|
||||||
{
|
{
|
||||||
from: 2,
|
from: 2,
|
||||||
to: 15
|
to: 15
|
||||||
},
|
},
|
||||||
'text4'
|
'text4'
|
||||||
)
|
)
|
||||||
expect(actual).toEqual(['tetext4t3', { from: 2, to: 7 }])
|
const expectedChanges: ContentEdits = [
|
||||||
|
{
|
||||||
|
from: 2,
|
||||||
|
to: 15,
|
||||||
|
insert: 'text4'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }])
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -4,26 +4,31 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stringSplice } from './utils/string-splice'
|
import type { ContentEdits } from './types/changes'
|
||||||
import type { CursorSelection } from '../../../editor/types'
|
import type { CursorSelection } from './types/cursor-selection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new {@link NoteDetails note state} but replaces the selected text.
|
* Creates a new {@link NoteDetails note state} but replaces the selected text.
|
||||||
*
|
*
|
||||||
* @param markdownContent The content of the document to modify
|
|
||||||
* @param selection If the selection has no to cursor then text will only be inserted.
|
* @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.
|
* If the selection has a to cursor then the selection will be replaced.
|
||||||
* @param insertText The text that should be inserted
|
* @param insertText The text that should be inserted
|
||||||
* @return The modified state
|
* @return The modified state
|
||||||
*/
|
*/
|
||||||
export const replaceSelection = (
|
export const replaceSelection = (
|
||||||
markdownContent: string,
|
|
||||||
selection: CursorSelection,
|
selection: CursorSelection,
|
||||||
insertText: string
|
insertText: string,
|
||||||
): [string, CursorSelection] => {
|
insertNewLine?: boolean
|
||||||
|
): [ContentEdits, CursorSelection] => {
|
||||||
const fromCursor = selection.from
|
const fromCursor = selection.from
|
||||||
const toCursor = selection.to ?? selection.from
|
const toCursor = selection.to ?? selection.from
|
||||||
|
|
||||||
const newContent = stringSplice(markdownContent, fromCursor, insertText, toCursor - fromCursor)
|
const changes: ContentEdits = [
|
||||||
return [newContent, { from: fromCursor, to: insertText.length + fromCursor }]
|
{
|
||||||
|
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
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ import {
|
||||||
searchForEndOfLine,
|
searchForEndOfLine,
|
||||||
searchForStartOfLine
|
searchForStartOfLine
|
||||||
} from './change-cursors-to-whole-line-if-no-to-cursor'
|
} from './change-cursors-to-whole-line-if-no-to-cursor'
|
||||||
import type { CursorSelection } from '../../../../editor/types'
|
import type { CursorSelection } from '../types/cursor-selection'
|
||||||
|
|
||||||
describe('changeCursorsToWholeLineIfNoToCursor', () => {
|
describe('changeCursorsToWholeLineIfNoToCursor', () => {
|
||||||
it(`returns the given selection if to cursor is present`, () => {
|
it(`returns the given selection if to cursor is present`, () => {
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CursorSelection } from '../../../../editor/types'
|
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.
|
* If the given cursor selection has no to position then the selection will be changed to cover the whole line of the from cursor.
|
|
@ -5,11 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { wrapSelection } from './wrap-selection'
|
import { wrapSelection } from './wrap-selection'
|
||||||
|
import type { ContentEdits } from './types/changes'
|
||||||
|
|
||||||
describe('wrap selection', () => {
|
describe('wrap selection', () => {
|
||||||
it(`doesn't modify any line if no to-cursor is present`, () => {
|
it(`doesn't modify any line if no to-cursor is present`, () => {
|
||||||
const actual = wrapSelection(
|
const actual = wrapSelection(
|
||||||
'a\nb\nc',
|
|
||||||
{
|
{
|
||||||
from: 0
|
from: 0
|
||||||
},
|
},
|
||||||
|
@ -17,12 +17,11 @@ describe('wrap selection', () => {
|
||||||
'after'
|
'after'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(actual).toStrictEqual(['a\nb\nc', { from: 0 }])
|
expect(actual).toStrictEqual([[], { from: 0 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it(`wraps the selected text in the same line`, () => {
|
it(`wraps the selected text in the same line`, () => {
|
||||||
const actual = wrapSelection(
|
const actual = wrapSelection(
|
||||||
'a\nb\nc',
|
|
||||||
{
|
{
|
||||||
from: 0,
|
from: 0,
|
||||||
to: 1
|
to: 1
|
||||||
|
@ -30,13 +29,24 @@ describe('wrap selection', () => {
|
||||||
'before',
|
'before',
|
||||||
'after'
|
'after'
|
||||||
)
|
)
|
||||||
|
const expectedChanges: ContentEdits = [
|
||||||
|
{
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
insert: 'before'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 1,
|
||||||
|
to: 1,
|
||||||
|
insert: 'after'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
expect(actual).toStrictEqual(['beforeaafter\nb\nc', { from: 0, to: 12 }])
|
expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 12 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it(`wraps the selected text in different lines`, () => {
|
it(`wraps the selected text in different lines`, () => {
|
||||||
const actual = wrapSelection(
|
const actual = wrapSelection(
|
||||||
'a\nb\nc',
|
|
||||||
{
|
{
|
||||||
from: 0,
|
from: 0,
|
||||||
to: 5
|
to: 5
|
||||||
|
@ -45,6 +55,19 @@ describe('wrap selection', () => {
|
||||||
'after'
|
'after'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(actual).toStrictEqual(['beforea\nb\ncafter', { from: 0, to: 16 }])
|
const expectedChanges: ContentEdits = [
|
||||||
|
{
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
insert: 'before'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 5,
|
||||||
|
to: 5,
|
||||||
|
insert: 'after'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 16 }])
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -4,13 +4,12 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stringSplice } from './utils/string-splice'
|
import type { ContentEdits } from './types/changes'
|
||||||
import type { CursorSelection } from '../../../editor/types'
|
import type { CursorSelection } from './types/cursor-selection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a copy of the given markdown content lines but wraps the selection.
|
* Creates a copy of the given markdown content lines but wraps the selection.
|
||||||
*
|
*
|
||||||
* @param markdownContent The lines of the document to modify
|
|
||||||
* @param selection If the selection has no to cursor then nothing will happen.
|
* @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.
|
* 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 symbolStart A text that will be inserted before the from cursor
|
||||||
|
@ -18,19 +17,28 @@ import type { CursorSelection } from '../../../editor/types'
|
||||||
* @return the modified copy of lines
|
* @return the modified copy of lines
|
||||||
*/
|
*/
|
||||||
export const wrapSelection = (
|
export const wrapSelection = (
|
||||||
markdownContent: string,
|
|
||||||
selection: CursorSelection,
|
selection: CursorSelection,
|
||||||
symbolStart: string,
|
symbolStart: string,
|
||||||
symbolEnd: string
|
symbolEnd: string
|
||||||
): [string, CursorSelection] => {
|
): [ContentEdits, CursorSelection] => {
|
||||||
if (selection.to === undefined) {
|
if (selection.to === undefined) {
|
||||||
return [markdownContent, selection]
|
return [[], selection]
|
||||||
}
|
}
|
||||||
|
|
||||||
const to = selection.to ?? selection.from
|
const to = selection.to
|
||||||
const from = selection.from
|
const from = selection.from
|
||||||
|
const changes: ContentEdits = [
|
||||||
|
{
|
||||||
|
from: from,
|
||||||
|
to: from,
|
||||||
|
insert: symbolStart
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: to,
|
||||||
|
to: to,
|
||||||
|
insert: symbolEnd
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const afterToModify = stringSplice(markdownContent, to, symbolEnd)
|
return [changes, { from, to: to + symbolEnd.length + symbolStart.length }]
|
||||||
const afterFromModify = stringSplice(afterToModify, from, symbolStart)
|
|
||||||
return [afterFromModify, { 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
|
* 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 { TableSizePickerPopover } from './table-size-picker-popover'
|
||||||
import { CustomTableSizeModal } from './custom-table-size-modal'
|
import { CustomTableSizeModal } from './custom-table-size-modal'
|
||||||
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
|
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
|
||||||
import { ShowIf } from '../../../../common/show-if/show-if'
|
import { replaceSelection } from '../formatters/replace-selection'
|
||||||
import { addTableAtCursor } from '../../../../../redux/note-details/methods'
|
import { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback'
|
||||||
|
import { createMarkdownTable } from './create-markdown-table'
|
||||||
|
|
||||||
enum PickerMode {
|
enum PickerMode {
|
||||||
INVISIBLE,
|
INVISIBLE,
|
||||||
|
@ -29,23 +30,22 @@ export const TablePickerButton: React.FC = () => {
|
||||||
const [pickerMode, setPickerMode] = useState<PickerMode>(PickerMode.INVISIBLE)
|
const [pickerMode, setPickerMode] = useState<PickerMode>(PickerMode.INVISIBLE)
|
||||||
const onDismiss = useCallback(() => setPickerMode(PickerMode.INVISIBLE), [])
|
const onDismiss = useCallback(() => setPickerMode(PickerMode.INVISIBLE), [])
|
||||||
const onShowModal = useCallback(() => setPickerMode(PickerMode.CUSTOM), [])
|
const onShowModal = useCallback(() => setPickerMode(PickerMode.CUSTOM), [])
|
||||||
|
const changeEditorContent = useChangeEditorContentCallback()
|
||||||
|
|
||||||
const onSizeSelect = useCallback((rows: number, columns: number) => {
|
const onSizeSelect = useCallback(
|
||||||
addTableAtCursor(rows, columns)
|
(rows: number, columns: number) => {
|
||||||
|
const table = createMarkdownTable(rows, columns)
|
||||||
|
changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, table, true))
|
||||||
setPickerMode(PickerMode.INVISIBLE)
|
setPickerMode(PickerMode.INVISIBLE)
|
||||||
}, [])
|
},
|
||||||
|
[changeEditorContent]
|
||||||
|
)
|
||||||
|
|
||||||
const tableTitle = useMemo(() => t('editor.editorToolbar.table.titleWithoutSize'), [t])
|
const tableTitle = useMemo(() => t('editor.editorToolbar.table.titleWithoutSize'), [t])
|
||||||
|
|
||||||
const button = useRef(null)
|
const button = useRef(null)
|
||||||
|
const toggleOverlayVisibility = useCallback(() => {
|
||||||
const toggleOverlayVisibility = useCallback(
|
setPickerMode((oldPickerMode) => (oldPickerMode === PickerMode.INVISIBLE ? PickerMode.GRID : PickerMode.INVISIBLE))
|
||||||
() =>
|
}, [])
|
||||||
setPickerMode((oldPickerMode) =>
|
|
||||||
oldPickerMode === PickerMode.INVISIBLE ? PickerMode.GRID : PickerMode.INVISIBLE
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const onOverlayHide = useCallback(() => {
|
const onOverlayHide = useCallback(() => {
|
||||||
setPickerMode((oldMode) => {
|
setPickerMode((oldMode) => {
|
||||||
|
@ -76,7 +76,8 @@ export const TablePickerButton: React.FC = () => {
|
||||||
variant='light'
|
variant='light'
|
||||||
onClick={toggleOverlayVisibility}
|
onClick={toggleOverlayVisibility}
|
||||||
title={tableTitle}
|
title={tableTitle}
|
||||||
ref={button}>
|
ref={button}
|
||||||
|
disabled={!changeEditorContent}>
|
||||||
<ForkAwesomeIcon icon='table' />
|
<ForkAwesomeIcon icon='table' />
|
||||||
</Button>
|
</Button>
|
||||||
<Overlay
|
<Overlay
|
||||||
|
@ -84,16 +85,14 @@ export const TablePickerButton: React.FC = () => {
|
||||||
onHide={onOverlayHide}
|
onHide={onOverlayHide}
|
||||||
show={pickerMode === PickerMode.GRID}
|
show={pickerMode === PickerMode.GRID}
|
||||||
placement={'bottom'}
|
placement={'bottom'}
|
||||||
rootClose={true}>
|
rootClose={pickerMode === PickerMode.GRID}>
|
||||||
{createPopoverElement}
|
{createPopoverElement}
|
||||||
</Overlay>
|
</Overlay>
|
||||||
<ShowIf condition={pickerMode === PickerMode.CUSTOM}>
|
|
||||||
<CustomTableSizeModal
|
<CustomTableSizeModal
|
||||||
showModal={pickerMode === PickerMode.CUSTOM}
|
showModal={pickerMode === PickerMode.CUSTOM}
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
onSizeSelect={onSizeSelect}
|
onSizeSelect={onSizeSelect}
|
||||||
/>
|
/>
|
||||||
</ShowIf>
|
|
||||||
</Fragment>
|
</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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -8,9 +8,25 @@ import React, { Fragment, Suspense } from 'react'
|
||||||
import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
|
import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
|
||||||
import { TablePickerButton } from './table-picker/table-picker-button'
|
import { TablePickerButton } from './table-picker/table-picker-button'
|
||||||
import styles from './tool-bar.module.scss'
|
import styles from './tool-bar.module.scss'
|
||||||
import { UploadImageButton } from './upload-image-button'
|
import { UploadImageButton } from './upload-image-button/upload-image-button'
|
||||||
import { ToolbarButton } from './toolbar-button'
|
import { BoldButton } from './buttons/bold-button'
|
||||||
import { FormatType } from '../../../../redux/note-details/types'
|
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'))
|
const EmojiPickerButton = React.lazy(() => import('./emoji-picker/emoji-picker-button'))
|
||||||
|
|
||||||
|
@ -18,32 +34,32 @@ export const ToolBar: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<ButtonToolbar className={`bg-light ${styles.toolbar}`}>
|
<ButtonToolbar className={`bg-light ${styles.toolbar}`}>
|
||||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||||
<ToolbarButton icon={'bold'} formatType={FormatType.BOLD} />
|
<BoldButton />
|
||||||
<ToolbarButton icon={'italic'} formatType={FormatType.ITALIC} />
|
<ItalicButton />
|
||||||
<ToolbarButton icon={'underline'} formatType={FormatType.UNDERLINE} />
|
<UnderlineButton />
|
||||||
<ToolbarButton icon={'strikethrough'} formatType={FormatType.STRIKETHROUGH} />
|
<StrikethroughButton />
|
||||||
<ToolbarButton icon={'subscript'} formatType={FormatType.SUBSCRIPT} />
|
<SubscriptButton />
|
||||||
<ToolbarButton icon={'superscript'} formatType={FormatType.SUPERSCRIPT} />
|
<SuperscriptButton />
|
||||||
<ToolbarButton icon={'eraser'} formatType={FormatType.HIGHLIGHT} />
|
<HighlightButton />
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||||
<ToolbarButton icon={'header'} formatType={FormatType.HEADER_LEVEL} />
|
<HeaderLevelButton />
|
||||||
<ToolbarButton icon={'code'} formatType={FormatType.CODE_FENCE} />
|
<CodeFenceButton />
|
||||||
<ToolbarButton icon={'quote-right'} formatType={FormatType.QUOTES} />
|
<QuotesButton />
|
||||||
<ToolbarButton icon={'list'} formatType={FormatType.UNORDERED_LIST} />
|
<UnorderedListButton />
|
||||||
<ToolbarButton icon={'list-ol'} formatType={FormatType.ORDERED_LIST} />
|
<OrderedListButton />
|
||||||
<ToolbarButton icon={'check-square'} formatType={FormatType.CHECK_LIST} />
|
<CheckListButton />
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||||
<ToolbarButton icon={'link'} formatType={FormatType.LINK} />
|
<LinkButton />
|
||||||
<ToolbarButton icon={'picture-o'} formatType={FormatType.IMAGE_LINK} />
|
<ImageLinkButton />
|
||||||
<UploadImageButton />
|
<UploadImageButton />
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||||
<TablePickerButton />
|
<TablePickerButton />
|
||||||
<ToolbarButton icon={'minus'} formatType={FormatType.HORIZONTAL_LINE} />
|
<HorizontalLineButton />
|
||||||
<ToolbarButton icon={'caret-square-o-down'} formatType={FormatType.COLLAPSIBLE_BLOCK} />
|
<CollapsibleBlockButton />
|
||||||
<ToolbarButton icon={'comment'} formatType={FormatType.COMMENT} />
|
<CommentButton />
|
||||||
<Suspense fallback={<Fragment />}>
|
<Suspense fallback={<Fragment />}>
|
||||||
<EmojiPickerButton />
|
<EmojiPickerButton />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
@ -8,28 +8,41 @@ import React, { useCallback, useMemo } from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
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 type { IconName } from '../../../common/fork-awesome/types'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 {
|
export interface ToolbarButtonProps {
|
||||||
icon: IconName
|
i18nKey: string
|
||||||
formatType: FormatType
|
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 { t } = useTranslation('', { keyPrefix: 'editor.editorToolbar' })
|
||||||
|
const changeEditorContent = useChangeEditorContentCallback()
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
formatSelection(formatType)
|
changeEditorContent?.(formatter)
|
||||||
}, [formatType])
|
}, [formatter, changeEditorContent])
|
||||||
|
const title = useMemo(() => t(i18nKey), [i18nKey, t])
|
||||||
const title = useMemo(() => t(formatType), [formatType, t])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant='light' onClick={onClick} title={title} {...cypressId('toolbar.' + formatType)}>
|
<Button
|
||||||
<ForkAwesomeIcon icon={icon} />
|
variant='light'
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
disabled={!changeEditorContent}
|
||||||
|
{...cypressId('toolbar.' + i18nKey)}>
|
||||||
|
<ForkAwesomeIcon icon={iconName} />
|
||||||
</Button>
|
</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)
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { uploadFile } from '../../../api/media'
|
|
||||||
import { getGlobalState } from '../../../redux'
|
|
||||||
import { supportedMimeTypes } from '../../common/upload-image-mimetypes'
|
|
||||||
import { replaceInMarkdownContent, replaceSelection } from '../../../redux/note-details/methods'
|
|
||||||
import { t } from 'i18next'
|
|
||||||
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
|
||||||
import type { CursorSelection } from '../../../redux/editor/types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads the given file and writes the progress into the given editor at the given cursor positions.
|
|
||||||
*
|
|
||||||
* @param file The file to upload
|
|
||||||
* @param cursorSelection The position where the progress message should be placed
|
|
||||||
* @param description The text that should be used in the description part of the resulting image tag
|
|
||||||
* @param additionalUrlText Additional text that should be inserted behind the link but within the tag
|
|
||||||
*/
|
|
||||||
export const handleUpload = (
|
|
||||||
file: File,
|
|
||||||
cursorSelection?: CursorSelection,
|
|
||||||
description?: string,
|
|
||||||
additionalUrlText?: string
|
|
||||||
): void => {
|
|
||||||
if (!file) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!supportedMimeTypes.includes(file.type)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const randomId = Math.random().toString(36).slice(7)
|
|
||||||
const uploadFileInfo = description
|
|
||||||
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description })
|
|
||||||
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
|
|
||||||
|
|
||||||
const uploadPlaceholder = ``
|
|
||||||
const noteId = getGlobalState().noteDetails.id
|
|
||||||
|
|
||||||
replaceSelection(uploadPlaceholder, cursorSelection)
|
|
||||||
uploadFile(noteId, file)
|
|
||||||
.then(({ url }) => {
|
|
||||||
replaceInMarkdownContent(uploadPlaceholder, ``)
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
|
|
||||||
replaceInMarkdownContent(uploadPlaceholder, `![upload of ${file.name} failed]()`)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -34,6 +34,7 @@ export const SidebarButton: React.FC<PropsWithChildren<SidebarButton>> = ({
|
||||||
buttonRef,
|
buttonRef,
|
||||||
hide,
|
hide,
|
||||||
variant,
|
variant,
|
||||||
|
disabled,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const variantClass = useMemo(() => {
|
const variantClass = useMemo(() => {
|
||||||
|
@ -44,6 +45,7 @@ export const SidebarButton: React.FC<PropsWithChildren<SidebarButton>> = ({
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
className={`${styles['sidebar-button']} ${variantClass} ${hide ? styles['hide'] : ''} ${className ?? ''}`}
|
className={`${styles['sidebar-button']} ${variantClass} ${hide ? styles['hide'] : ''} ${className ?? ''}`}
|
||||||
|
disabled={disabled}
|
||||||
{...props}>
|
{...props}>
|
||||||
<ShowIf condition={!!icon}>
|
<ShowIf condition={!!icon}>
|
||||||
<span className={`sidebar-button-icon ${styles['sidebar-icon']}`}>
|
<span className={`sidebar-button-icon ${styles['sidebar-icon']}`}>
|
||||||
|
|
|
@ -1,39 +1,40 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Fragment, useCallback, useRef } from 'react'
|
import React, { Fragment, useCallback, useRef } from 'react'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content'
|
|
||||||
import { setNoteContent } from '../../../../redux/note-details/methods'
|
|
||||||
import { SidebarButton } from '../sidebar-button/sidebar-button'
|
import { SidebarButton } from '../sidebar-button/sidebar-button'
|
||||||
import { UploadInput } from '../upload-input'
|
import { UploadInput } from '../upload-input'
|
||||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||||
|
import { useChangeEditorContentCallback } from '../../change-content-context/use-change-editor-content-callback'
|
||||||
|
import { ShowIf } from '../../../common/show-if/show-if'
|
||||||
|
import { FileContentFormat, readFile } from '../../../../utils/read-file'
|
||||||
|
|
||||||
export const ImportMarkdownSidebarEntry: React.FC = () => {
|
export const ImportMarkdownSidebarEntry: React.FC = () => {
|
||||||
const markdownContent = useNoteMarkdownContent()
|
|
||||||
useTranslation()
|
useTranslation()
|
||||||
|
const changeEditorContent = useChangeEditorContentCallback()
|
||||||
|
|
||||||
const onImportMarkdown = useCallback(
|
const onImportMarkdown = useCallback(
|
||||||
(file: File) => {
|
async (file: File): Promise<void> => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
const content = await readFile(file, FileContentFormat.TEXT)
|
||||||
const fileReader = new FileReader()
|
changeEditorContent?.(({ markdownContent }) => {
|
||||||
fileReader.addEventListener('load', () => {
|
const newContent = (markdownContent.length === 0 ? '' : '\n') + content
|
||||||
const newContent = fileReader.result as string
|
return [
|
||||||
setNoteContent(markdownContent.length === 0 ? newContent : `${markdownContent}\n${newContent}`)
|
[
|
||||||
})
|
{
|
||||||
fileReader.addEventListener('loadend', () => {
|
from: markdownContent.length,
|
||||||
resolve()
|
to: markdownContent.length,
|
||||||
})
|
insert: newContent
|
||||||
fileReader.addEventListener('error', (error) => {
|
}
|
||||||
reject(error)
|
],
|
||||||
})
|
undefined
|
||||||
fileReader.readAsText(file)
|
]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[markdownContent]
|
[changeEditorContent]
|
||||||
)
|
)
|
||||||
|
|
||||||
const clickRef = useRef<() => void>()
|
const clickRef = useRef<() => void>()
|
||||||
|
@ -43,15 +44,21 @@ export const ImportMarkdownSidebarEntry: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<SidebarButton {...cypressId('menu-import-markdown-button')} icon={'file-text-o'} onClick={buttonClick}>
|
<SidebarButton
|
||||||
|
{...cypressId('menu-import-markdown-button')}
|
||||||
|
icon={'file-text-o'}
|
||||||
|
onClick={buttonClick}
|
||||||
|
disabled={!changeEditorContent}>
|
||||||
<Trans i18nKey={'editor.import.file'} />
|
<Trans i18nKey={'editor.import.file'} />
|
||||||
</SidebarButton>
|
</SidebarButton>
|
||||||
|
<ShowIf condition={!!changeEditorContent}>
|
||||||
<UploadInput
|
<UploadInput
|
||||||
onLoad={onImportMarkdown}
|
onLoad={onImportMarkdown}
|
||||||
{...cypressId('menu-import-markdown-input')}
|
{...cypressId('menu-import-markdown-input')}
|
||||||
acceptedFiles={'.md, text/markdown, text/plain'}
|
acceptedFiles={'.md, text/markdown, text/plain'}
|
||||||
onClickRef={clickRef}
|
onClickRef={clickRef}
|
||||||
/>
|
/>
|
||||||
|
</ShowIf>
|
||||||
</Fragment>
|
</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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -20,6 +20,7 @@ export interface SidebarEntryProps extends PropsWithDataCypressId {
|
||||||
hide?: boolean
|
hide?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarMenuProps {
|
export interface SidebarMenuProps {
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -65,6 +65,8 @@ export const ImportHistoryButton: React.FC = () => {
|
||||||
resetInputField()
|
resetInputField()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
//TODO: [mrdrogdrog] The following whole block can be shortened using our `readFile` util.
|
||||||
|
// But I won't do it right now because the whole components needs a make over and that's definitely out of scope for my current PR.
|
||||||
const fileReader = new FileReader()
|
const fileReader = new FileReader()
|
||||||
fileReader.onload = (event) => {
|
fileReader.onload = (event) => {
|
||||||
if (event.target && event.target.result) {
|
if (event.target && event.target.result) {
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -8,24 +8,10 @@ import { useRendererToEditorCommunicator } from '../../../../editor-page/render-
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { CommunicationMessageType } from '../../../../render-page/window-post-message-communicator/rendering-message'
|
import { CommunicationMessageType } from '../../../../render-page/window-post-message-communicator/rendering-message'
|
||||||
import { Logger } from '../../../../../utils/logger'
|
import { Logger } from '../../../../../utils/logger'
|
||||||
|
import { FileContentFormat, readFile } from '../../../../../utils/read-file'
|
||||||
|
|
||||||
const log = new Logger('useOnImageUpload')
|
const log = new Logger('useOnImageUpload')
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a {@link File} to a data url.
|
|
||||||
*
|
|
||||||
* @param file The file to convert
|
|
||||||
* @return The file content represented as data url
|
|
||||||
*/
|
|
||||||
const readFileAsDataUrl = (file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
reader.onload = () => resolve(reader.result as string)
|
|
||||||
reader.onerror = (error) => reject(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a callback that sends a {@link File file} to the editor via iframe communication.
|
* Provides a callback that sends a {@link File file} to the editor via iframe communication.
|
||||||
*
|
*
|
||||||
|
@ -40,7 +26,7 @@ export const useOnImageUpload = (
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
readFileAsDataUrl(file)
|
readFile(file, FileContentFormat.DATA_URL)
|
||||||
.then((dataUri) => {
|
.then((dataUri) => {
|
||||||
communicator.sendMessageToOtherSide({
|
communicator.sendMessageToOtherSide({
|
||||||
type: CommunicationMessageType.IMAGE_UPLOAD,
|
type: CommunicationMessageType.IMAGE_UPLOAD,
|
||||||
|
|
|
@ -14,11 +14,6 @@ export enum EditorConfigActionType {
|
||||||
SET_SMART_PASTE = 'editor/preferences/setSmartPaste'
|
SET_SMART_PASTE = 'editor/preferences/setSmartPaste'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CursorSelection {
|
|
||||||
from: number
|
|
||||||
to?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditorConfig {
|
export interface EditorConfig {
|
||||||
editorMode: EditorMode
|
editorMode: EditorMode
|
||||||
syncScroll: boolean
|
syncScroll: boolean
|
||||||
|
|
|
@ -1,221 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Mock } from 'ts-mockery'
|
|
||||||
import * as wrapSelectionModule from './formatters/wrap-selection'
|
|
||||||
import { applyFormatTypeToMarkdownLines } from './apply-format-type-to-markdown-lines'
|
|
||||||
import type { CursorSelection } from '../../editor/types'
|
|
||||||
import { FormatType } from '../types'
|
|
||||||
import * as changeCursorsToWholeLineIfNoToCursorModule from './formatters/utils/change-cursors-to-whole-line-if-no-to-cursor'
|
|
||||||
import * as prependLinesOfSelectionModule from './formatters/prepend-lines-of-selection'
|
|
||||||
import * as replaceSelectionModule from './formatters/replace-selection'
|
|
||||||
import * as addLinkModule from './formatters/add-link'
|
|
||||||
|
|
||||||
describe('apply format type to markdown lines', () => {
|
|
||||||
Mock.configure('jest')
|
|
||||||
|
|
||||||
const markdownContentMock = 'input'
|
|
||||||
const cursorSelectionMock = Mock.of<CursorSelection>()
|
|
||||||
|
|
||||||
const wrapSelectionMock = jest.spyOn(wrapSelectionModule, 'wrapSelection')
|
|
||||||
const wrapSelectionMockResponse = Mock.of<[string, CursorSelection]>()
|
|
||||||
|
|
||||||
const changeCursorsToWholeLineIfNoToCursorMock = jest.spyOn(
|
|
||||||
changeCursorsToWholeLineIfNoToCursorModule,
|
|
||||||
'changeCursorsToWholeLineIfNoToCursor'
|
|
||||||
)
|
|
||||||
const changeCursorsToWholeLineIfNoToCursorMockResponse = Mock.of<CursorSelection>()
|
|
||||||
|
|
||||||
const prependLinesOfSelectionMock = jest.spyOn(prependLinesOfSelectionModule, 'prependLinesOfSelection')
|
|
||||||
|
|
||||||
const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection')
|
|
||||||
const replaceSelectionMockResponse = Mock.of<[string, CursorSelection]>()
|
|
||||||
|
|
||||||
const addLinkMock = jest.spyOn(addLinkModule, 'addLink')
|
|
||||||
const addLinkMockResponse = Mock.of<[string, CursorSelection]>()
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
wrapSelectionMock.mockReturnValue(wrapSelectionMockResponse)
|
|
||||||
changeCursorsToWholeLineIfNoToCursorMock.mockReturnValue(changeCursorsToWholeLineIfNoToCursorMockResponse)
|
|
||||||
prependLinesOfSelectionMock.mockImplementation(
|
|
||||||
(
|
|
||||||
markdownContent: string,
|
|
||||||
selection: CursorSelection,
|
|
||||||
generatePrefix: (line: string, lineIndexInBlock: number) => string
|
|
||||||
): [string, CursorSelection] => {
|
|
||||||
return [generatePrefix(markdownContent, 0) + markdownContent, selection]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
replaceSelectionMock.mockReturnValue(replaceSelectionMockResponse)
|
|
||||||
addLinkMock.mockReturnValue(addLinkMockResponse)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
jest.resetAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type bold', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.BOLD)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '**', '**')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type italic', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.ITALIC)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '*', '*')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type strikethrough', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.STRIKETHROUGH)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '~~', '~~')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type underline', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.UNDERLINE)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '++', '++')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type subscript', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.SUBSCRIPT)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '~', '~')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type superscript', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.SUPERSCRIPT)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '^', '^')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type highlight', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.HIGHLIGHT)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '==', '==')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type code fence', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.CODE_FENCE)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentMock, cursorSelectionMock)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(
|
|
||||||
markdownContentMock,
|
|
||||||
changeCursorsToWholeLineIfNoToCursorMockResponse,
|
|
||||||
'```\n',
|
|
||||||
'\n```'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type unordered list', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.UNORDERED_LIST)
|
|
||||||
expect(result).toEqual(['- input', cursorSelectionMock])
|
|
||||||
expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, expect.anything())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type ordered list', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.ORDERED_LIST)
|
|
||||||
expect(result).toEqual(['1. input', cursorSelectionMock])
|
|
||||||
expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, expect.anything())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type check list', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.CHECK_LIST)
|
|
||||||
expect(result).toEqual(['- [ ] input', cursorSelectionMock])
|
|
||||||
expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, expect.anything())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type quotes', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.QUOTES)
|
|
||||||
expect(result).toEqual(['> input', cursorSelectionMock])
|
|
||||||
expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, expect.anything())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type horizontal line with only from cursor', () => {
|
|
||||||
const randomCursorPosition = 138743857
|
|
||||||
const result = applyFormatTypeToMarkdownLines(
|
|
||||||
markdownContentMock,
|
|
||||||
{ from: randomCursorPosition },
|
|
||||||
FormatType.HORIZONTAL_LINE
|
|
||||||
)
|
|
||||||
expect(result).toEqual(replaceSelectionMockResponse)
|
|
||||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { from: randomCursorPosition }, `\n----`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type horizontal line with from and to cursor', () => {
|
|
||||||
const fromCursor = Math.random()
|
|
||||||
const toCursor = Math.random()
|
|
||||||
|
|
||||||
const result = applyFormatTypeToMarkdownLines(
|
|
||||||
markdownContentMock,
|
|
||||||
{ from: fromCursor, to: toCursor },
|
|
||||||
FormatType.HORIZONTAL_LINE
|
|
||||||
)
|
|
||||||
expect(result).toEqual(replaceSelectionMockResponse)
|
|
||||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { from: toCursor }, `\n----`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type comment with only from cursor', () => {
|
|
||||||
const fromCursor = Math.random()
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, { from: fromCursor }, FormatType.COMMENT)
|
|
||||||
expect(result).toEqual(replaceSelectionMockResponse)
|
|
||||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { from: fromCursor }, `\n> []`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type comment with from and to cursor', () => {
|
|
||||||
const fromCursor = 0
|
|
||||||
const toCursor = 1
|
|
||||||
|
|
||||||
const result = applyFormatTypeToMarkdownLines(
|
|
||||||
markdownContentMock,
|
|
||||||
{ from: fromCursor, to: toCursor },
|
|
||||||
FormatType.COMMENT
|
|
||||||
)
|
|
||||||
expect(result).toEqual(replaceSelectionMockResponse)
|
|
||||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { from: toCursor }, `\n> []`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type collapsible block', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(
|
|
||||||
markdownContentMock,
|
|
||||||
cursorSelectionMock,
|
|
||||||
FormatType.COLLAPSIBLE_BLOCK
|
|
||||||
)
|
|
||||||
expect(result).toBe(wrapSelectionMockResponse)
|
|
||||||
expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentMock, cursorSelectionMock)
|
|
||||||
expect(wrapSelectionMock).toBeCalledWith(
|
|
||||||
markdownContentMock,
|
|
||||||
changeCursorsToWholeLineIfNoToCursorMockResponse,
|
|
||||||
':::spoiler Toggle label\n',
|
|
||||||
'\n:::'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type header level with existing level', () => {
|
|
||||||
const inputLines = '# text'
|
|
||||||
const result = applyFormatTypeToMarkdownLines(inputLines, cursorSelectionMock, FormatType.HEADER_LEVEL)
|
|
||||||
expect(result).toEqual(['## text', cursorSelectionMock])
|
|
||||||
expect(prependLinesOfSelectionMock).toBeCalledWith(inputLines, cursorSelectionMock, expect.anything())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type link', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.LINK)
|
|
||||||
expect(result).toEqual(addLinkMockResponse)
|
|
||||||
expect(addLinkMock).toBeCalledWith(markdownContentMock, cursorSelectionMock)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process the format type image link', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.IMAGE_LINK)
|
|
||||||
expect(result).toEqual(addLinkMockResponse)
|
|
||||||
expect(addLinkMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '!')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can process an unknown format type ', () => {
|
|
||||||
const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, 'UNKNOWN' as FormatType)
|
|
||||||
expect(result).toEqual([markdownContentMock, cursorSelectionMock])
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,74 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { FormatType } from '../types'
|
|
||||||
import { wrapSelection } from './formatters/wrap-selection'
|
|
||||||
import { addLink } from './formatters/add-link'
|
|
||||||
import { prependLinesOfSelection } from './formatters/prepend-lines-of-selection'
|
|
||||||
import type { CursorSelection } from '../../editor/types'
|
|
||||||
import { changeCursorsToWholeLineIfNoToCursor } from './formatters/utils/change-cursors-to-whole-line-if-no-to-cursor'
|
|
||||||
import { replaceSelection } from './formatters/replace-selection'
|
|
||||||
|
|
||||||
export const applyFormatTypeToMarkdownLines = (
|
|
||||||
markdownContent: string,
|
|
||||||
selection: CursorSelection,
|
|
||||||
type: FormatType
|
|
||||||
): [string, CursorSelection] => {
|
|
||||||
switch (type) {
|
|
||||||
case FormatType.BOLD:
|
|
||||||
return wrapSelection(markdownContent, selection, '**', '**')
|
|
||||||
case FormatType.ITALIC:
|
|
||||||
return wrapSelection(markdownContent, selection, '*', '*')
|
|
||||||
case FormatType.STRIKETHROUGH:
|
|
||||||
return wrapSelection(markdownContent, selection, '~~', '~~')
|
|
||||||
case FormatType.UNDERLINE:
|
|
||||||
return wrapSelection(markdownContent, selection, '++', '++')
|
|
||||||
case FormatType.SUBSCRIPT:
|
|
||||||
return wrapSelection(markdownContent, selection, '~', '~')
|
|
||||||
case FormatType.SUPERSCRIPT:
|
|
||||||
return wrapSelection(markdownContent, selection, '^', '^')
|
|
||||||
case FormatType.HIGHLIGHT:
|
|
||||||
return wrapSelection(markdownContent, selection, '==', '==')
|
|
||||||
case FormatType.CODE_FENCE:
|
|
||||||
return wrapSelection(
|
|
||||||
markdownContent,
|
|
||||||
changeCursorsToWholeLineIfNoToCursor(markdownContent, selection),
|
|
||||||
'```\n',
|
|
||||||
'\n```'
|
|
||||||
)
|
|
||||||
case FormatType.UNORDERED_LIST:
|
|
||||||
return prependLinesOfSelection(markdownContent, selection, () => `- `)
|
|
||||||
case FormatType.ORDERED_LIST:
|
|
||||||
return prependLinesOfSelection(
|
|
||||||
markdownContent,
|
|
||||||
selection,
|
|
||||||
(line, lineIndexInBlock) => `${lineIndexInBlock + 1}. `
|
|
||||||
)
|
|
||||||
case FormatType.CHECK_LIST:
|
|
||||||
return prependLinesOfSelection(markdownContent, selection, () => `- [ ] `)
|
|
||||||
case FormatType.QUOTES:
|
|
||||||
return prependLinesOfSelection(markdownContent, selection, () => `> `)
|
|
||||||
case FormatType.HEADER_LEVEL:
|
|
||||||
return prependLinesOfSelection(markdownContent, selection, (line) => (line.startsWith('#') ? `#` : `# `))
|
|
||||||
case FormatType.HORIZONTAL_LINE:
|
|
||||||
return replaceSelection(markdownContent, { from: selection.to ?? selection.from }, '\n----')
|
|
||||||
case FormatType.COMMENT:
|
|
||||||
return replaceSelection(markdownContent, { from: selection.to ?? selection.from }, '\n> []')
|
|
||||||
case FormatType.COLLAPSIBLE_BLOCK:
|
|
||||||
return wrapSelection(
|
|
||||||
markdownContent,
|
|
||||||
changeCursorsToWholeLineIfNoToCursor(markdownContent, selection),
|
|
||||||
':::spoiler Toggle label\n',
|
|
||||||
'\n:::'
|
|
||||||
)
|
|
||||||
case FormatType.LINK:
|
|
||||||
return addLink(markdownContent, selection)
|
|
||||||
case FormatType.IMAGE_LINK:
|
|
||||||
return addLink(markdownContent, selection, '!')
|
|
||||||
default:
|
|
||||||
return [markdownContent, selection]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,86 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { addLink } from './add-link'
|
|
||||||
|
|
||||||
describe('add link', () => {
|
|
||||||
describe('without to-cursor', () => {
|
|
||||||
it('inserts a link', () => {
|
|
||||||
const actual = addLink('', { from: 0 }, '')
|
|
||||||
expect(actual).toEqual(['[](https://)', { from: 0, to: 12 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts a link into a line', () => {
|
|
||||||
const actual = addLink('aa', { from: 1 }, '')
|
|
||||||
expect(actual).toEqual(['a[](https://)a', { from: 1, to: 13 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts a link with a prefix', () => {
|
|
||||||
const actual = addLink('', { from: 0 }, 'prefix')
|
|
||||||
expect(actual).toEqual(['prefix[](https://)', { from: 0, to: 18 }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('with a normal text selected', () => {
|
|
||||||
it('wraps the selection', () => {
|
|
||||||
const actual = addLink(
|
|
||||||
'a',
|
|
||||||
{
|
|
||||||
from: 0,
|
|
||||||
to: 1
|
|
||||||
},
|
|
||||||
''
|
|
||||||
)
|
|
||||||
expect(actual).toEqual(['[a](https://)', { from: 0, to: 13 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('wraps the selection inside of a line', () => {
|
|
||||||
const actual = addLink('aba', { from: 1, to: 2 }, '')
|
|
||||||
expect(actual).toEqual(['a[b](https://)a', { from: 1, to: 14 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('wraps the selection with a prefix', () => {
|
|
||||||
const actual = addLink('a', { from: 0, to: 1 }, 'prefix')
|
|
||||||
expect(actual).toEqual(['prefix[a](https://)', { from: 0, to: 19 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('wraps a multi line selection', () => {
|
|
||||||
const actual = addLink('a\nb\nc', { from: 0, to: 5 }, '')
|
|
||||||
expect(actual).toEqual(['[a\nb\nc](https://)', { from: 0, to: 17 }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('with a url selected', () => {
|
|
||||||
it('wraps the selection', () => {
|
|
||||||
const actual = addLink(
|
|
||||||
'https://google.com',
|
|
||||||
{
|
|
||||||
from: 0,
|
|
||||||
to: 18
|
|
||||||
},
|
|
||||||
''
|
|
||||||
)
|
|
||||||
expect(actual).toEqual(['[](https://google.com)', { from: 0, to: 22 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('wraps the selection with a prefix', () => {
|
|
||||||
const actual = addLink(
|
|
||||||
'https://google.com',
|
|
||||||
{
|
|
||||||
from: 0,
|
|
||||||
to: 18
|
|
||||||
},
|
|
||||||
'prefix'
|
|
||||||
)
|
|
||||||
expect(actual).toEqual(['prefix[](https://google.com)', { 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 }, '')
|
|
||||||
expect(actual).toEqual(['[a\nhttps://google.com\nc](https://)', { from: 0, to: 34 }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,44 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { prependLinesOfSelection } from './prepend-lines-of-selection'
|
|
||||||
|
|
||||||
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}_`
|
|
||||||
)
|
|
||||||
expect(actual).toStrictEqual(['a\ntext_0_b\nc', { from: 2, to: 10 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
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}_`
|
|
||||||
)
|
|
||||||
expect(actual).toStrictEqual(['a\ntext_0_b\nc', { from: 2, to: 10 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('replaces multiple lines', () => {
|
|
||||||
const actual = prependLinesOfSelection(
|
|
||||||
'a\nb\nc\nd\ne',
|
|
||||||
{
|
|
||||||
from: 2,
|
|
||||||
to: 6
|
|
||||||
},
|
|
||||||
(line, lineIndexInBlock) => `text_${lineIndexInBlock}_`
|
|
||||||
)
|
|
||||||
expect(actual).toEqual(['a\ntext_0_b\ntext_1_c\ntext_2_d\ne', { from: 2, to: 28 }])
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,49 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CursorSelection } from '../../../editor/types'
|
|
||||||
import { searchForEndOfLine, searchForStartOfLine } from './utils/change-cursors-to-whole-line-if-no-to-cursor'
|
|
||||||
import { stringSplice } from './utils/string-splice'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a copy of the given markdown content lines but modifies the whole selected lines.
|
|
||||||
*
|
|
||||||
* @param markdownContentLines 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 replacer A function that modifies the selected lines
|
|
||||||
* @return the modified copy of lines
|
|
||||||
*/
|
|
||||||
export const prependLinesOfSelection = (
|
|
||||||
markdownContentLines: string,
|
|
||||||
selection: CursorSelection,
|
|
||||||
generatePrefix: (line: string, lineIndexInBlock: number) => string
|
|
||||||
): [string, CursorSelection] => {
|
|
||||||
let currentContent = markdownContentLines
|
|
||||||
let toIndex = selection.to ?? selection.from
|
|
||||||
let currentIndex = selection.from
|
|
||||||
let indexInBlock = 0
|
|
||||||
let newStartOfSelection = selection.from
|
|
||||||
let newEndOfSelection = toIndex
|
|
||||||
while (currentIndex <= toIndex && currentIndex < currentContent.length) {
|
|
||||||
const startOfLine = searchForStartOfLine(currentContent, currentIndex)
|
|
||||||
if (startOfLine < newStartOfSelection) {
|
|
||||||
newStartOfSelection = startOfLine
|
|
||||||
}
|
|
||||||
const endOfLine = searchForEndOfLine(currentContent, currentIndex)
|
|
||||||
const line = currentContent.slice(startOfLine, endOfLine)
|
|
||||||
const replacement = generatePrefix(line, indexInBlock)
|
|
||||||
indexInBlock += 1
|
|
||||||
currentContent = stringSplice(currentContent, startOfLine, replacement)
|
|
||||||
toIndex += replacement.length
|
|
||||||
const newEndOfLine = endOfLine + replacement.length
|
|
||||||
currentIndex = newEndOfLine + 1
|
|
||||||
if (newEndOfLine > newEndOfSelection) {
|
|
||||||
newEndOfSelection = newEndOfLine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [currentContent, { from: newStartOfSelection, to: newEndOfSelection }]
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { stringSplice } from './string-splice'
|
|
||||||
|
|
||||||
describe('string splice', () => {
|
|
||||||
it(`won't modify a string without deletion or text to add`, () => {
|
|
||||||
expect(stringSplice('I am your friendly test string!', 0, '')).toEqual('I am your friendly test string!')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can insert a string in the string', () => {
|
|
||||||
expect(stringSplice('I am your friendly test string!', 10, 'very ')).toEqual('I am your very friendly test string!')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can append a string if the index is beyond the upper bounds', () => {
|
|
||||||
expect(stringSplice('I am your friendly test string!', 100, ' And will ever be!')).toEqual(
|
|
||||||
'I am your friendly test string! And will ever be!'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can prepend a string if the index is beyond the lower bounds', () => {
|
|
||||||
expect(stringSplice('I am your friendly test string!', -100, 'Here I come! ')).toEqual(
|
|
||||||
'Here I come! I am your friendly test string!'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can delete parts of a string', () => {
|
|
||||||
expect(stringSplice('I am your friendly test string!', 4, '', 5)).toEqual('I am friendly test string!')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can delete and insert parts of a string', () => {
|
|
||||||
expect(stringSplice('I am your friendly test string!', 10, 'great', 8)).toEqual('I am your great test string!')
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`will ignore a negative delete length`, () => {
|
|
||||||
expect(stringSplice('I am your friendly test string!', 100, '', -100)).toEqual('I am your friendly test string!')
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,24 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modifies a string by inserting another string and/or deleting characters.
|
|
||||||
*
|
|
||||||
* @param text Text to modify
|
|
||||||
* @param changePosition The position where the other text should be inserted and characters should be deleted
|
|
||||||
* @param textToInsert The text to insert
|
|
||||||
* @param deleteLength The number of characters to delete
|
|
||||||
* @return The modified string
|
|
||||||
*/
|
|
||||||
export const stringSplice = (
|
|
||||||
text: string,
|
|
||||||
changePosition: number,
|
|
||||||
textToInsert: string,
|
|
||||||
deleteLength?: number
|
|
||||||
): string => {
|
|
||||||
const correctedDeleteLength = deleteLength === undefined || deleteLength < 0 ? 0 : deleteLength
|
|
||||||
return text.slice(0, changePosition) + textToInsert + text.slice(changePosition + correctedDeleteLength)
|
|
||||||
}
|
|
|
@ -7,20 +7,14 @@
|
||||||
import { store } from '..'
|
import { store } from '..'
|
||||||
import type { Note, NotePermissions } from '../../api/notes/types'
|
import type { Note, NotePermissions } from '../../api/notes/types'
|
||||||
import type {
|
import type {
|
||||||
AddTableAtCursorAction,
|
|
||||||
FormatSelectionAction,
|
|
||||||
FormatType,
|
|
||||||
InsertTextAtCursorAction,
|
|
||||||
ReplaceInMarkdownContentAction,
|
|
||||||
SetNoteDetailsFromServerAction,
|
SetNoteDetailsFromServerAction,
|
||||||
SetNoteDocumentContentAction,
|
SetNoteDocumentContentAction,
|
||||||
SetNotePermissionsFromServerAction,
|
SetNotePermissionsFromServerAction,
|
||||||
UpdateCursorPositionAction,
|
UpdateCursorPositionAction,
|
||||||
UpdateNoteTitleByFirstHeadingAction,
|
UpdateNoteTitleByFirstHeadingAction
|
||||||
UpdateTaskListCheckboxAction
|
|
||||||
} from './types'
|
} from './types'
|
||||||
import { NoteDetailsActionType } from './types'
|
import { NoteDetailsActionType } from './types'
|
||||||
import type { CursorSelection } from '../editor/types'
|
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
|
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
|
||||||
|
@ -66,60 +60,9 @@ export const updateNoteTitleByFirstHeading = (firstHeading?: string): void => {
|
||||||
} as UpdateNoteTitleByFirstHeadingAction)
|
} as UpdateNoteTitleByFirstHeadingAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes a checkbox state in the note document content. Triggered when a checkbox in the rendering is clicked.
|
|
||||||
*
|
|
||||||
* @param lineInDocumentContent The line in the document content to change.
|
|
||||||
* @param checked true if the checkbox is checked, false otherwise.
|
|
||||||
*/
|
|
||||||
export const setCheckboxInMarkdownContent = (lineInDocumentContent: number, checked: boolean): void => {
|
|
||||||
store.dispatch({
|
|
||||||
type: NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX,
|
|
||||||
checkboxChecked: checked,
|
|
||||||
changedLine: lineInDocumentContent
|
|
||||||
} as UpdateTaskListCheckboxAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces a string in the markdown content in the global application state.
|
|
||||||
*
|
|
||||||
* @param replaceable The string that should be replaced
|
|
||||||
* @param replacement The replacement for the replaceable
|
|
||||||
*/
|
|
||||||
export const replaceInMarkdownContent = (replaceable: string, replacement: string): void => {
|
|
||||||
store.dispatch({
|
|
||||||
type: NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT,
|
|
||||||
placeholder: replaceable,
|
|
||||||
replacement
|
|
||||||
} as ReplaceInMarkdownContentAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateCursorPositions = (selection: CursorSelection): void => {
|
export const updateCursorPositions = (selection: CursorSelection): void => {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION,
|
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION,
|
||||||
selection
|
selection
|
||||||
} as UpdateCursorPositionAction)
|
} as UpdateCursorPositionAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatSelection = (formatType: FormatType): void => {
|
|
||||||
store.dispatch({
|
|
||||||
type: NoteDetailsActionType.FORMAT_SELECTION,
|
|
||||||
formatType
|
|
||||||
} as FormatSelectionAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addTableAtCursor = (rows: number, columns: number): void => {
|
|
||||||
store.dispatch({
|
|
||||||
type: NoteDetailsActionType.ADD_TABLE_AT_CURSOR,
|
|
||||||
rows,
|
|
||||||
columns
|
|
||||||
} as AddTableAtCursorAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const replaceSelection = (text: string, cursorSelection?: CursorSelection): void => {
|
|
||||||
store.dispatch({
|
|
||||||
type: NoteDetailsActionType.REPLACE_SELECTION,
|
|
||||||
text,
|
|
||||||
cursorSelection
|
|
||||||
} as InsertTextAtCursorAction)
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,11 +13,6 @@ import { buildStateFromUpdatedMarkdownContent } from './build-state-from-updated
|
||||||
import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-update-cursor-position'
|
import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-update-cursor-position'
|
||||||
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
|
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
|
||||||
import { buildStateFromServerDto } from './reducers/build-state-from-set-note-data-from-server'
|
import { buildStateFromServerDto } from './reducers/build-state-from-set-note-data-from-server'
|
||||||
import { buildStateFromAddTableAtCursor } from './reducers/build-state-from-add-table-at-cursor'
|
|
||||||
import { buildStateFromReplaceSelection } from './reducers/build-state-from-replace-selection'
|
|
||||||
import { buildStateFromTaskListUpdate } from './reducers/build-state-from-task-list-update'
|
|
||||||
import { buildStateFromSelectionFormat } from './reducers/build-state-from-selection-format'
|
|
||||||
import { buildStateFromReplaceInMarkdownContent } from './reducers/build-state-from-replace-in-markdown-content'
|
|
||||||
import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions'
|
import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions'
|
||||||
|
|
||||||
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
||||||
|
@ -35,16 +30,6 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
||||||
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
|
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
|
||||||
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
|
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
|
||||||
return buildStateFromServerDto(action.noteFromServer)
|
return buildStateFromServerDto(action.noteFromServer)
|
||||||
case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX:
|
|
||||||
return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked)
|
|
||||||
case NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT:
|
|
||||||
return buildStateFromReplaceInMarkdownContent(state, action.placeholder, action.replacement)
|
|
||||||
case NoteDetailsActionType.FORMAT_SELECTION:
|
|
||||||
return buildStateFromSelectionFormat(state, action.formatType)
|
|
||||||
case NoteDetailsActionType.ADD_TABLE_AT_CURSOR:
|
|
||||||
return buildStateFromAddTableAtCursor(state, action.rows, action.columns)
|
|
||||||
case NoteDetailsActionType.REPLACE_SELECTION:
|
|
||||||
return buildStateFromReplaceSelection(state, action.text, action.cursorSelection)
|
|
||||||
default:
|
default:
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { buildStateFromAddTableAtCursor } from './build-state-from-add-table-at-cursor'
|
|
||||||
import { initialState } from '../initial-state'
|
|
||||||
|
|
||||||
describe('build state from add table at cursor', () => {
|
|
||||||
it('fails if number of rows is negative', () => {
|
|
||||||
expect(() =>
|
|
||||||
buildStateFromAddTableAtCursor(
|
|
||||||
{
|
|
||||||
...initialState
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fails if number of columns is negative', () => {
|
|
||||||
expect(() =>
|
|
||||||
buildStateFromAddTableAtCursor(
|
|
||||||
{
|
|
||||||
...initialState
|
|
||||||
},
|
|
||||||
1,
|
|
||||||
-1
|
|
||||||
)
|
|
||||||
).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('generates a table with the correct size', () => {
|
|
||||||
const actual = buildStateFromAddTableAtCursor(
|
|
||||||
{
|
|
||||||
...initialState,
|
|
||||||
markdownContent: { plain: 'a\nb\nc', lines: ['a', 'b', 'c'], lineStartIndexes: [0, 2, 4] },
|
|
||||||
selection: {
|
|
||||||
from: 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
3,
|
|
||||||
3
|
|
||||||
)
|
|
||||||
expect(actual.markdownContent.plain).toEqual(
|
|
||||||
'a\n\n| # 1 | # 2 | # 3 |\n' +
|
|
||||||
'| ---- | ---- | ---- |\n' +
|
|
||||||
'| Text | Text | Text |\n' +
|
|
||||||
'| Text | Text | Text |\n' +
|
|
||||||
'| Text | Text | Text |b\n' +
|
|
||||||
'c'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,48 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
|
||||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
|
||||||
import { replaceSelection } from '../format-selection/formatters/replace-selection'
|
|
||||||
import { createNumberRangeArray } from '../../../components/common/number-range/number-range'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies the given {@link NoteDetails note details state} but adds a markdown table with the given table at the end of the cursor selection.
|
|
||||||
*
|
|
||||||
* @param state The original {@link NoteDetails}
|
|
||||||
* @param rows The number of rows of the new table
|
|
||||||
* @param columns The number of columns of the new table
|
|
||||||
* @return the copied but modified {@link NoteDetails note details state}
|
|
||||||
*/
|
|
||||||
export const buildStateFromAddTableAtCursor = (state: NoteDetails, rows: number, columns: number): NoteDetails => {
|
|
||||||
const table = createMarkdownTable(rows, columns)
|
|
||||||
const [newContent, newSelection] = replaceSelection(
|
|
||||||
state.markdownContent.plain,
|
|
||||||
{ from: state.selection.to ?? state.selection.from },
|
|
||||||
table
|
|
||||||
)
|
|
||||||
const newState = buildStateFromUpdatedMarkdownContent(state, newContent)
|
|
||||||
return {
|
|
||||||
...newState,
|
|
||||||
selection: newSelection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a markdown table with the given size.
|
|
||||||
*
|
|
||||||
* @param rows The number of table rows
|
|
||||||
* @param columns The number of table columns
|
|
||||||
* @return The created markdown table
|
|
||||||
*/
|
|
||||||
const createMarkdownTable = (rows: number, columns: number): string => {
|
|
||||||
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(() => 'Text').join(' | ') + ' |').join('\n')
|
|
||||||
return `\n${head}\n${divider}\n${body}`
|
|
||||||
}
|
|
|
@ -1,37 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as buildStateFromUpdatedMarkdownContentModule from '../build-state-from-updated-markdown-content'
|
|
||||||
import { Mock } from 'ts-mockery'
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
|
||||||
import { buildStateFromReplaceInMarkdownContent } from './build-state-from-replace-in-markdown-content'
|
|
||||||
import { initialState } from '../initial-state'
|
|
||||||
|
|
||||||
describe('build state from replace in markdown content', () => {
|
|
||||||
const buildStateFromUpdatedMarkdownContentMock = jest.spyOn(
|
|
||||||
buildStateFromUpdatedMarkdownContentModule,
|
|
||||||
'buildStateFromUpdatedMarkdownContent'
|
|
||||||
)
|
|
||||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
buildStateFromUpdatedMarkdownContentMock.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates the markdown content with the replacement', () => {
|
|
||||||
const startState: NoteDetails = {
|
|
||||||
...initialState,
|
|
||||||
markdownContent: { ...initialState.markdownContent, plain: 'replaceable' }
|
|
||||||
}
|
|
||||||
const result = buildStateFromReplaceInMarkdownContent(startState, 'replaceable', 'replacement')
|
|
||||||
expect(result).toBe(mockedNoteDetails)
|
|
||||||
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, 'replacement')
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,39 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
|
||||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
|
||||||
|
|
||||||
const replaceAllExists = String.prototype.replaceAll !== undefined
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A replace-all string function that uses a polyfill if the environment doesn't
|
|
||||||
* support replace-all (like node 14 for unit tests).
|
|
||||||
* TODO: Remove polyfill when node 14 is removed
|
|
||||||
*
|
|
||||||
* @param haystack The string that should be modified
|
|
||||||
* @param needle The string that should get replaced
|
|
||||||
* @param replacement The string that should replace
|
|
||||||
* @return The modified string
|
|
||||||
*/
|
|
||||||
const replaceAll = (haystack: string, needle: string, replacement: string): string =>
|
|
||||||
replaceAllExists ? haystack.replaceAll(needle, replacement) : haystack.split(needle).join(replacement)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a {@link NoteDetails} redux state with a modified markdown content.
|
|
||||||
*
|
|
||||||
* @param state The previous redux state
|
|
||||||
* @param replaceable The string that should be replaced in the old markdown content
|
|
||||||
* @param replacement The string that should replace the replaceable
|
|
||||||
* @return An updated {@link NoteDetails} redux state
|
|
||||||
*/
|
|
||||||
export const buildStateFromReplaceInMarkdownContent = (
|
|
||||||
state: NoteDetails,
|
|
||||||
replaceable: string,
|
|
||||||
replacement: string
|
|
||||||
): NoteDetails => {
|
|
||||||
return buildStateFromUpdatedMarkdownContent(state, replaceAll(state.markdownContent.plain, replaceable, replacement))
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as buildStateFromUpdatedMarkdownContentLinesModule from '../build-state-from-updated-markdown-content'
|
|
||||||
import * as replaceSelectionModule from '../format-selection/formatters/replace-selection'
|
|
||||||
import { Mock } from 'ts-mockery'
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
|
||||||
import { buildStateFromReplaceSelection } from './build-state-from-replace-selection'
|
|
||||||
import { initialState } from '../initial-state'
|
|
||||||
import type { CursorSelection } from '../../editor/types'
|
|
||||||
|
|
||||||
describe('build state from replace selection', () => {
|
|
||||||
const buildStateFromUpdatedMarkdownContentMock = jest.spyOn(
|
|
||||||
buildStateFromUpdatedMarkdownContentLinesModule,
|
|
||||||
'buildStateFromUpdatedMarkdownContent'
|
|
||||||
)
|
|
||||||
const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection')
|
|
||||||
const mockedNoteDetails = { content: 'mocked' } as unknown as NoteDetails
|
|
||||||
const mockedFormattedContent = 'formatted'
|
|
||||||
const mockedCursor = Mock.of<CursorSelection>()
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails)
|
|
||||||
replaceSelectionMock.mockImplementation(() => [mockedFormattedContent, mockedCursor])
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
buildStateFromUpdatedMarkdownContentMock.mockReset()
|
|
||||||
replaceSelectionMock.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('builds a new state with the provided cursor', () => {
|
|
||||||
const originalLines = 'original'
|
|
||||||
const startState = {
|
|
||||||
...initialState,
|
|
||||||
markdownContent: { plain: originalLines, lines: [originalLines], lineStartIndexes: [0] }
|
|
||||||
}
|
|
||||||
const customCursor = Mock.of<CursorSelection>()
|
|
||||||
const textReplacement = 'replacement'
|
|
||||||
|
|
||||||
const result = buildStateFromReplaceSelection(startState, 'replacement', customCursor)
|
|
||||||
|
|
||||||
expect(result).toStrictEqual({ content: 'mocked', selection: mockedCursor })
|
|
||||||
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, mockedFormattedContent)
|
|
||||||
expect(replaceSelectionMock).toHaveBeenCalledWith(originalLines, customCursor, textReplacement)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('builds a new state with the state cursor', () => {
|
|
||||||
const originalLines = 'original'
|
|
||||||
const selection = Mock.of<CursorSelection>()
|
|
||||||
const startState: NoteDetails = {
|
|
||||||
...initialState,
|
|
||||||
markdownContent: { plain: originalLines, lines: [originalLines], lineStartIndexes: [0] },
|
|
||||||
selection
|
|
||||||
}
|
|
||||||
const textReplacement = 'replacement'
|
|
||||||
|
|
||||||
const result = buildStateFromReplaceSelection(startState, 'replacement')
|
|
||||||
|
|
||||||
expect(result).toStrictEqual({ content: 'mocked', selection: mockedCursor })
|
|
||||||
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, mockedFormattedContent)
|
|
||||||
expect(replaceSelectionMock).toHaveBeenCalledWith(originalLines, selection, textReplacement)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,23 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
|
||||||
import type { CursorSelection } from '../../editor/types'
|
|
||||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
|
||||||
import { replaceSelection } from '../format-selection/formatters/replace-selection'
|
|
||||||
|
|
||||||
export const buildStateFromReplaceSelection = (state: NoteDetails, text: string, cursorSelection?: CursorSelection) => {
|
|
||||||
const [newContent, newSelection] = replaceSelection(
|
|
||||||
state.markdownContent.plain,
|
|
||||||
cursorSelection ? cursorSelection : state.selection,
|
|
||||||
text
|
|
||||||
)
|
|
||||||
const newState = buildStateFromUpdatedMarkdownContent(state, newContent)
|
|
||||||
return {
|
|
||||||
...newState,
|
|
||||||
selection: newSelection
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as buildStateFromUpdatedMarkdownContentLinesModule from '../build-state-from-updated-markdown-content'
|
|
||||||
import { Mock } from 'ts-mockery'
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
|
||||||
import * as applyFormatTypeToMarkdownLinesModule from '../format-selection/apply-format-type-to-markdown-lines'
|
|
||||||
import { buildStateFromSelectionFormat } from './build-state-from-selection-format'
|
|
||||||
import { initialState } from '../initial-state'
|
|
||||||
import { FormatType } from '../types'
|
|
||||||
import type { CursorSelection } from '../../editor/types'
|
|
||||||
|
|
||||||
describe('build state from selection format', () => {
|
|
||||||
const buildStateFromUpdatedMarkdownContentMock = jest.spyOn(
|
|
||||||
buildStateFromUpdatedMarkdownContentLinesModule,
|
|
||||||
'buildStateFromUpdatedMarkdownContent'
|
|
||||||
)
|
|
||||||
const mockedNoteDetails = { content: 'mocked' } as unknown as NoteDetails
|
|
||||||
const applyFormatTypeToMarkdownLinesMock = jest.spyOn(
|
|
||||||
applyFormatTypeToMarkdownLinesModule,
|
|
||||||
'applyFormatTypeToMarkdownLines'
|
|
||||||
)
|
|
||||||
const mockedFormattedContent = 'formatted'
|
|
||||||
const mockedCursor = Mock.of<CursorSelection>()
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails)
|
|
||||||
applyFormatTypeToMarkdownLinesMock.mockImplementation(() => [mockedFormattedContent, mockedCursor])
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
buildStateFromUpdatedMarkdownContentMock.mockReset()
|
|
||||||
applyFormatTypeToMarkdownLinesMock.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('builds a new state with the formatted code', () => {
|
|
||||||
const originalContent = 'original'
|
|
||||||
const startState: NoteDetails = {
|
|
||||||
...initialState,
|
|
||||||
markdownContent: { ...initialState.markdownContent, plain: originalContent },
|
|
||||||
selection: mockedCursor
|
|
||||||
}
|
|
||||||
const result = buildStateFromSelectionFormat(startState, FormatType.BOLD)
|
|
||||||
expect(result).toStrictEqual({ content: 'mocked', selection: mockedCursor })
|
|
||||||
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, mockedFormattedContent)
|
|
||||||
expect(applyFormatTypeToMarkdownLinesMock).toHaveBeenCalledWith(originalContent, mockedCursor, FormatType.BOLD)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,19 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
|
||||||
import type { FormatType } from '../types'
|
|
||||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
|
||||||
import { applyFormatTypeToMarkdownLines } from '../format-selection/apply-format-type-to-markdown-lines'
|
|
||||||
|
|
||||||
export const buildStateFromSelectionFormat = (state: NoteDetails, type: FormatType): NoteDetails => {
|
|
||||||
const [newContent, newSelection] = applyFormatTypeToMarkdownLines(state.markdownContent.plain, state.selection, type)
|
|
||||||
const newState = buildStateFromUpdatedMarkdownContent(state, newContent)
|
|
||||||
return {
|
|
||||||
...newState,
|
|
||||||
selection: newSelection
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,9 +5,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { initialState } from '../initial-state'
|
import { initialState } from '../initial-state'
|
||||||
import type { CursorSelection } from '../../editor/types'
|
|
||||||
import { Mock } from 'ts-mockery'
|
import { Mock } from 'ts-mockery'
|
||||||
import { buildStateFromUpdateCursorPosition } from './build-state-from-update-cursor-position'
|
import { buildStateFromUpdateCursorPosition } from './build-state-from-update-cursor-position'
|
||||||
|
import type { CursorSelection } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
describe('build state from update cursor position', () => {
|
describe('build state from update cursor position', () => {
|
||||||
it('creates a new state with the given cursor', () => {
|
it('creates a new state with the given cursor', () => {
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
import type { NoteDetails } from '../types/note-details'
|
||||||
import type { CursorSelection } from '../../editor/types'
|
import type { CursorSelection } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
export const buildStateFromUpdateCursorPosition = (state: NoteDetails, selection: CursorSelection): NoteDetails => {
|
export const buildStateFromUpdateCursorPosition = (state: NoteDetails, selection: CursorSelection): NoteDetails => {
|
||||||
const correctedSelection = isFromAfterTo(selection)
|
const correctedSelection = isFromAfterTo(selection)
|
||||||
|
|
|
@ -6,40 +6,14 @@
|
||||||
|
|
||||||
import type { Action } from 'redux'
|
import type { Action } from 'redux'
|
||||||
import type { Note, NotePermissions } from '../../api/notes/types'
|
import type { Note, NotePermissions } from '../../api/notes/types'
|
||||||
import type { CursorSelection } from '../editor/types'
|
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
export enum NoteDetailsActionType {
|
export enum NoteDetailsActionType {
|
||||||
SET_DOCUMENT_CONTENT = 'note-details/content/set',
|
SET_DOCUMENT_CONTENT = 'note-details/content/set',
|
||||||
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
||||||
SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/set',
|
SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/set',
|
||||||
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
||||||
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox',
|
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition'
|
||||||
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition',
|
|
||||||
REPLACE_IN_MARKDOWN_CONTENT = 'note-details/replace-in-markdown-content',
|
|
||||||
FORMAT_SELECTION = 'note-details/format-selection',
|
|
||||||
ADD_TABLE_AT_CURSOR = 'note-details/add-table-at-cursor',
|
|
||||||
REPLACE_SELECTION = 'note-details/replace-selection'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum FormatType {
|
|
||||||
BOLD = 'bold',
|
|
||||||
ITALIC = 'italic',
|
|
||||||
STRIKETHROUGH = 'strikethrough',
|
|
||||||
UNDERLINE = 'underline',
|
|
||||||
SUBSCRIPT = 'subscript',
|
|
||||||
SUPERSCRIPT = 'superscript',
|
|
||||||
HIGHLIGHT = 'highlight',
|
|
||||||
CODE_FENCE = 'code',
|
|
||||||
UNORDERED_LIST = 'unorderedList',
|
|
||||||
ORDERED_LIST = 'orderedList',
|
|
||||||
CHECK_LIST = 'checkList',
|
|
||||||
QUOTES = 'blockquote',
|
|
||||||
HORIZONTAL_LINE = 'horizontalLine',
|
|
||||||
COMMENT = 'comment',
|
|
||||||
COLLAPSIBLE_BLOCK = 'collapsibleBlock',
|
|
||||||
HEADER_LEVEL = 'header',
|
|
||||||
LINK = 'link',
|
|
||||||
IMAGE_LINK = 'imageLink'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NoteDetailsActions =
|
export type NoteDetailsActions =
|
||||||
|
@ -47,12 +21,7 @@ export type NoteDetailsActions =
|
||||||
| SetNoteDetailsFromServerAction
|
| SetNoteDetailsFromServerAction
|
||||||
| SetNotePermissionsFromServerAction
|
| SetNotePermissionsFromServerAction
|
||||||
| UpdateNoteTitleByFirstHeadingAction
|
| UpdateNoteTitleByFirstHeadingAction
|
||||||
| UpdateTaskListCheckboxAction
|
|
||||||
| UpdateCursorPositionAction
|
| UpdateCursorPositionAction
|
||||||
| ReplaceInMarkdownContentAction
|
|
||||||
| FormatSelectionAction
|
|
||||||
| AddTableAtCursorAction
|
|
||||||
| InsertTextAtCursorAction
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action for updating the document content of the currently loaded note.
|
* Action for updating the document content of the currently loaded note.
|
||||||
|
@ -86,39 +55,7 @@ export interface UpdateNoteTitleByFirstHeadingAction extends Action<NoteDetailsA
|
||||||
firstHeading?: string
|
firstHeading?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Action for manipulating the document content of the currently loaded note by changing the checked state of a task list checkbox.
|
|
||||||
*/
|
|
||||||
export interface UpdateTaskListCheckboxAction extends Action<NoteDetailsActionType> {
|
|
||||||
type: NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX
|
|
||||||
changedLine: number
|
|
||||||
checkboxChecked: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplaceInMarkdownContentAction extends Action<NoteDetailsActionType> {
|
|
||||||
type: NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT
|
|
||||||
placeholder: string
|
|
||||||
replacement: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCursorPositionAction extends Action<NoteDetailsActionType> {
|
export interface UpdateCursorPositionAction extends Action<NoteDetailsActionType> {
|
||||||
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION
|
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION
|
||||||
selection: CursorSelection
|
selection: CursorSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormatSelectionAction extends Action<NoteDetailsActionType> {
|
|
||||||
type: NoteDetailsActionType.FORMAT_SELECTION
|
|
||||||
formatType: FormatType
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddTableAtCursorAction extends Action<NoteDetailsActionType> {
|
|
||||||
type: NoteDetailsActionType.ADD_TABLE_AT_CURSOR
|
|
||||||
rows: number
|
|
||||||
columns: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InsertTextAtCursorAction extends Action<NoteDetailsActionType> {
|
|
||||||
type: NoteDetailsActionType.REPLACE_SELECTION
|
|
||||||
text: string
|
|
||||||
cursorSelection?: CursorSelection
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
|
|
||||||
import type { SlideOptions } from './slide-show-options'
|
import type { SlideOptions } from './slide-show-options'
|
||||||
import type { ISO6391 } from './iso6391'
|
import type { ISO6391 } from './iso6391'
|
||||||
import type { CursorSelection } from '../../editor/types'
|
|
||||||
import type { NoteMetadata } from '../../../api/notes/types'
|
import type { NoteMetadata } from '../../../api/notes/types'
|
||||||
|
import type { CursorSelection } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
|
||||||
type UnnecessaryNoteAttributes = 'updatedAt' | 'createdAt' | 'tags' | 'description'
|
type UnnecessaryNoteAttributes = 'updatedAt' | 'createdAt' | 'tags' | 'description'
|
||||||
|
|
||||||
|
|
18
src/utils/read-file.test.ts
Normal file
18
src/utils/read-file.test.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FileContentFormat, readFile } from './read-file'
|
||||||
|
|
||||||
|
describe('read file', () => {
|
||||||
|
it('reads files as text', async () => {
|
||||||
|
const a = await readFile(new Blob(['Kinderriegel'], { type: 'text/plain' }), FileContentFormat.TEXT)
|
||||||
|
expect(a).toBe('Kinderriegel')
|
||||||
|
})
|
||||||
|
it('reads files as data url', async () => {
|
||||||
|
const a = await readFile(new Blob(['Kinderriegel'], { type: 'text/plain' }), FileContentFormat.DATA_URL)
|
||||||
|
expect(a).toBe('data:text/plain;base64,S2luZGVycmllZ2Vs')
|
||||||
|
})
|
||||||
|
})
|
40
src/utils/read-file.ts
Normal file
40
src/utils/read-file.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum FileContentFormat {
|
||||||
|
TEXT,
|
||||||
|
DATA_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the given {@link File}.
|
||||||
|
*
|
||||||
|
* @param file The file to read
|
||||||
|
* @param fileReaderMode Defines as what the file content should be formatted.
|
||||||
|
* @throws Error if an invalid read mode was given or if the file couldn't be read.
|
||||||
|
* @return the file content
|
||||||
|
*/
|
||||||
|
export const readFile = async (file: Blob, fileReaderMode: FileContentFormat): Promise<string> => {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const fileReader = new FileReader()
|
||||||
|
fileReader.addEventListener('load', () => {
|
||||||
|
resolve(fileReader.result as string)
|
||||||
|
})
|
||||||
|
fileReader.addEventListener('error', (error) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
switch (fileReaderMode) {
|
||||||
|
case FileContentFormat.DATA_URL:
|
||||||
|
fileReader.readAsDataURL(file)
|
||||||
|
break
|
||||||
|
case FileContentFormat.TEXT:
|
||||||
|
fileReader.readAsText(file)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown file reader mode')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue