Move toolbar functions into redux reducer (#1763)

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-01-26 17:14:28 +01:00 committed by GitHub
parent a6a2251c88
commit b30cc5b390
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2481 additions and 2303 deletions

View file

@ -16,6 +16,16 @@ export enum EditorConfigActionType {
SET_SMART_PASTE = 'editor/preferences/setSmartPaste'
}
export interface CursorPosition {
line: number
character: number
}
export interface CursorSelection {
from: CursorPosition
to?: CursorPosition
}
export interface EditorConfig {
editorMode: EditorMode
syncScroll: boolean

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { store } from '../index'
import { getGlobalState, store } from '../index'
import type {
HistoryEntry,
HistoryExportJson,
@ -69,7 +69,7 @@ export const updateLocalHistoryEntry = (noteId: string, newEntry: HistoryEntry):
}
export const removeHistoryEntry = async (noteId: string): Promise<void> => {
const entryToDelete = store.getState().history.find((entry) => entry.identifier === noteId)
const entryToDelete = getGlobalState().history.find((entry) => entry.identifier === noteId)
if (entryToDelete && entryToDelete.origin === HistoryEntryOrigin.REMOTE) {
await deleteHistoryEntry(noteId)
}
@ -81,7 +81,7 @@ export const removeHistoryEntry = async (noteId: string): Promise<void> => {
}
export const toggleHistoryEntryPinning = async (noteId: string): Promise<void> => {
const state = store.getState().history
const state = getGlobalState().history
const entryToUpdate = state.find((entry) => entry.identifier === noteId)
if (!entryToUpdate) {
return Promise.reject(`History entry for note '${noteId}' not found`)
@ -100,7 +100,7 @@ export const toggleHistoryEntryPinning = async (noteId: string): Promise<void> =
}
export const downloadHistory = (): void => {
const history = store.getState().history
const history = getGlobalState().history
history.forEach((entry: Partial<HistoryEntry>) => {
delete entry.origin
})
@ -129,7 +129,7 @@ export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntry[] =
export const refreshHistoryState = async (): Promise<void> => {
const localEntries = loadLocalHistory()
if (!store.getState().user) {
if (!getGlobalState().user) {
setHistoryEntries(localEntries)
return
}
@ -143,7 +143,7 @@ export const safeRefreshHistoryState = (): void => {
}
export const storeLocalHistory = (): void => {
const history = store.getState().history
const history = getGlobalState().history
const localEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.LOCAL)
const entriesWithoutOrigin = localEntries.map((entry) => ({
...entry,
@ -153,10 +153,10 @@ export const storeLocalHistory = (): void => {
}
export const storeRemoteHistory = (): Promise<void> => {
if (!store.getState().user) {
if (!getGlobalState().user) {
return Promise.resolve()
}
const history = store.getState().history
const history = getGlobalState().history
const remoteEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.REMOTE)
const remoteEntryDtos = remoteEntries.map(historyEntryToHistoryEntryPutDto)
return postHistory(remoteEntryDtos)

View file

@ -7,5 +7,8 @@
import { createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'
import { allReducers } from './reducers'
import type { ApplicationState } from './application-state'
export const store = createStore(allReducers, composeWithDevTools())
export const getGlobalState = (): ApplicationState => store.getState()

View file

@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteDetails } from './types/note-details'
import { extractFrontmatter } from './frontmatter-extractor/extractor'
import { initialState } from './initial-state'
import type { PresentFrontmatterExtractionResult } from './frontmatter-extractor/types'
import { createNoteFrontmatterFromYaml } from './raw-note-frontmatter-parser/parser'
import { generateNoteTitle } from './generate-note-title'
/**
* Copies a {@link NoteDetails} but with another markdown content.
* @param state The previous state.
* @param markdownContent The new note markdown content consisting of the frontmatter and markdown part.
* @return An updated {@link NoteDetails} state.
*/
export const buildStateFromUpdatedMarkdownContent = (state: NoteDetails, markdownContent: string): NoteDetails => {
return buildStateFromMarkdownContentAndLines(state, markdownContent, markdownContent.split('\n'))
}
/**
* Copies a {@link NoteDetails} but with another markdown content.
* @param state The previous state.
* @param markdownContentLines The new note markdown content as separate lines consisting of the frontmatter and markdown part.
* @return An updated {@link NoteDetails} state.
*/
export const buildStateFromUpdatedMarkdownContentLines = (
state: NoteDetails,
markdownContentLines: string[]
): NoteDetails => {
return buildStateFromMarkdownContentAndLines(state, markdownContentLines.join('\n'), markdownContentLines)
}
const buildStateFromMarkdownContentAndLines = (
state: NoteDetails,
markdownContent: string,
markdownContentLines: string[]
): NoteDetails => {
const frontmatterExtraction = extractFrontmatter(markdownContentLines)
if (frontmatterExtraction.isPresent) {
return buildStateFromFrontmatterUpdate(
{
...state,
markdownContent: markdownContent,
markdownContentLines: markdownContentLines
},
frontmatterExtraction
)
} else {
return {
...state,
markdownContent: markdownContent,
markdownContentLines: markdownContentLines,
rawFrontmatter: '',
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
frontmatter: initialState.frontmatter,
frontmatterRendererInfo: initialState.frontmatterRendererInfo
}
}
}
/**
* Builds a {@link NoteDetails} redux state from extracted frontmatter data.
* @param state The previous redux state.
* @param frontmatterExtraction The result of the frontmatter extraction containing the raw data and the line offset.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromFrontmatterUpdate = (
state: NoteDetails,
frontmatterExtraction: PresentFrontmatterExtractionResult
): NoteDetails => {
if (frontmatterExtraction.rawText === state.rawFrontmatter) {
return state
}
try {
const frontmatter = createNoteFrontmatterFromYaml(frontmatterExtraction.rawText)
return {
...state,
rawFrontmatter: frontmatterExtraction.rawText,
frontmatter: frontmatter,
noteTitle: generateNoteTitle(frontmatter, state.firstHeading),
frontmatterRendererInfo: {
lineOffset: frontmatterExtraction.lineOffset,
deprecatedSyntax: frontmatter.deprecatedTagsSyntax,
frontmatterInvalid: false,
slideOptions: frontmatter.slideOptions
}
}
} catch (e) {
return {
...state,
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
rawFrontmatter: frontmatterExtraction.rawText,
frontmatter: initialState.frontmatter,
frontmatterRendererInfo: {
lineOffset: frontmatterExtraction.lineOffset,
deprecatedSyntax: false,
frontmatterInvalid: true,
slideOptions: initialState.frontmatterRendererInfo.slideOptions
}
}
}
}

View file

@ -0,0 +1,237 @@
/*
* 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 { CursorPosition, 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 replaceLinesOfSelectionModule from './formatters/replace-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 markdownContentLinesMock = ['input']
const cursorSelectionMock = Mock.of<CursorSelection>()
const wrapSelectionMock = jest.spyOn(wrapSelectionModule, 'wrapSelection')
const wrapSelectionMockResponse = Mock.of<string[]>()
const changeCursorsToWholeLineIfNoToCursorMock = jest.spyOn(
changeCursorsToWholeLineIfNoToCursorModule,
'changeCursorsToWholeLineIfNoToCursor'
)
const changeCursorsToWholeLineIfNoToCursorMockResponse = Mock.of<CursorSelection>()
const replaceLinesOfSelectionMock = jest.spyOn(replaceLinesOfSelectionModule, 'replaceLinesOfSelection')
const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection')
const replaceSelectionMockResponse = Mock.of<string[]>()
const addLinkMock = jest.spyOn(addLinkModule, 'addLink')
const addLinkMockResponse = Mock.of<string[]>()
beforeAll(() => {
wrapSelectionMock.mockReturnValue(wrapSelectionMockResponse)
changeCursorsToWholeLineIfNoToCursorMock.mockReturnValue(changeCursorsToWholeLineIfNoToCursorMockResponse)
replaceLinesOfSelectionMock.mockImplementation(
(
lines: string[],
selection: CursorSelection,
replacer: (line: string, lineIndex: number) => string
): string[] => {
return lines.map(replacer)
}
)
replaceSelectionMock.mockReturnValue(replaceSelectionMockResponse)
addLinkMock.mockReturnValue(addLinkMockResponse)
})
afterAll(() => {
jest.resetAllMocks()
})
it('can process the format type bold', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.BOLD)
expect(result).toBe(wrapSelectionMockResponse)
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '**', '**')
})
it('can process the format type italic', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.ITALIC)
expect(result).toBe(wrapSelectionMockResponse)
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '*', '*')
})
it('can process the format type strikethrough', () => {
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
cursorSelectionMock,
FormatType.STRIKETHROUGH
)
expect(result).toBe(wrapSelectionMockResponse)
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '~~', '~~')
})
it('can process the format type underline', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.UNDERLINE)
expect(result).toBe(wrapSelectionMockResponse)
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '++', '++')
})
it('can process the format type subscript', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.SUBSCRIPT)
expect(result).toBe(wrapSelectionMockResponse)
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '~', '~')
})
it('can process the format type superscript', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.SUPERSCRIPT)
expect(result).toBe(wrapSelectionMockResponse)
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '^', '^')
})
it('can process the format type highlight', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.HIGHLIGHT)
expect(result).toBe(wrapSelectionMockResponse)
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '==', '==')
})
it('can process the format type code fence', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.CODE_FENCE)
expect(result).toBe(wrapSelectionMockResponse)
expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock)
expect(wrapSelectionMock).toBeCalledWith(
markdownContentLinesMock,
changeCursorsToWholeLineIfNoToCursorMockResponse,
'```\n',
'\n```'
)
})
it('can process the format type unordered list', () => {
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
cursorSelectionMock,
FormatType.UNORDERED_LIST
)
expect(result).toEqual(['- input'])
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
})
it('can process the format type unordered list', () => {
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
cursorSelectionMock,
FormatType.ORDERED_LIST
)
expect(result).toEqual(['1. input'])
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
})
it('can process the format type check list', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.CHECK_LIST)
expect(result).toEqual(['- [ ] input'])
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
})
it('can process the format type quotes', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.QUOTES)
expect(result).toEqual(['> input'])
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
})
it('can process the format type horizontal line with only from cursor', () => {
const fromCursor = Mock.of<CursorPosition>()
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
{ from: fromCursor },
FormatType.HORIZONTAL_LINE
)
expect(result).toEqual(replaceSelectionMockResponse)
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: fromCursor }, `\n----`)
})
it('can process the format type horizontal line with from and to cursor', () => {
const fromCursor = Mock.of<CursorPosition>()
const toCursor = Mock.of<CursorPosition>()
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
{ from: fromCursor, to: toCursor },
FormatType.HORIZONTAL_LINE
)
expect(result).toEqual(replaceSelectionMockResponse)
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: toCursor }, `\n----`)
})
it('can process the format type comment with only from cursor', () => {
const fromCursor = Mock.of<CursorPosition>()
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, { from: fromCursor }, FormatType.COMMENT)
expect(result).toEqual(replaceSelectionMockResponse)
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: fromCursor }, `\n> []`)
})
it('can process the format type comment with from and to cursor', () => {
const fromCursor = Mock.of<CursorPosition>()
const toCursor = Mock.of<CursorPosition>()
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
{ from: fromCursor, to: toCursor },
FormatType.COMMENT
)
expect(result).toEqual(replaceSelectionMockResponse)
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: toCursor }, `\n> []`)
})
it('can process the format type collapsible block', () => {
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
cursorSelectionMock,
FormatType.COLLAPSIBLE_BLOCK
)
expect(result).toBe(wrapSelectionMockResponse)
expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock)
expect(wrapSelectionMock).toBeCalledWith(
markdownContentLinesMock,
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'])
expect(replaceLinesOfSelectionMock).toBeCalledWith(inputLines, cursorSelectionMock, expect.anything())
})
it('can process the format type link', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.LINK)
expect(result).toEqual(addLinkMockResponse)
expect(addLinkMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock)
})
it('can process the format type image link', () => {
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.IMAGE_LINK)
expect(result).toEqual(addLinkMockResponse)
expect(addLinkMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '!')
})
it('can process an unknown format type ', () => {
const result = applyFormatTypeToMarkdownLines(
markdownContentLinesMock,
cursorSelectionMock,
'UNKNOWN' as FormatType
)
expect(result).toEqual(markdownContentLinesMock)
})
})

View file

@ -0,0 +1,76 @@
/*
* 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 { replaceLinesOfSelection } from './formatters/replace-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 = (
markdownContentLines: string[],
selection: CursorSelection,
type: FormatType
): string[] => {
switch (type) {
case FormatType.BOLD:
return wrapSelection(markdownContentLines, selection, '**', '**')
case FormatType.ITALIC:
return wrapSelection(markdownContentLines, selection, '*', '*')
case FormatType.STRIKETHROUGH:
return wrapSelection(markdownContentLines, selection, '~~', '~~')
case FormatType.UNDERLINE:
return wrapSelection(markdownContentLines, selection, '++', '++')
case FormatType.SUBSCRIPT:
return wrapSelection(markdownContentLines, selection, '~', '~')
case FormatType.SUPERSCRIPT:
return wrapSelection(markdownContentLines, selection, '^', '^')
case FormatType.HIGHLIGHT:
return wrapSelection(markdownContentLines, selection, '==', '==')
case FormatType.CODE_FENCE:
return wrapSelection(
markdownContentLines,
changeCursorsToWholeLineIfNoToCursor(markdownContentLines, selection),
'```\n',
'\n```'
)
case FormatType.UNORDERED_LIST:
return replaceLinesOfSelection(markdownContentLines, selection, (line) => `- ${line}`)
case FormatType.ORDERED_LIST:
return replaceLinesOfSelection(
markdownContentLines,
selection,
(line, lineIndexInBlock) => `${lineIndexInBlock + 1}. ${line}`
)
case FormatType.CHECK_LIST:
return replaceLinesOfSelection(markdownContentLines, selection, (line) => `- [ ] ${line}`)
case FormatType.QUOTES:
return replaceLinesOfSelection(markdownContentLines, selection, (line) => `> ${line}`)
case FormatType.HEADER_LEVEL:
return replaceLinesOfSelection(markdownContentLines, selection, (line) =>
line.startsWith('#') ? `#${line}` : `# ${line}`
)
case FormatType.HORIZONTAL_LINE:
return replaceSelection(markdownContentLines, { from: selection.to ?? selection.from }, '\n----')
case FormatType.COMMENT:
return replaceSelection(markdownContentLines, { from: selection.to ?? selection.from }, '\n> []')
case FormatType.COLLAPSIBLE_BLOCK:
return wrapSelection(
markdownContentLines,
changeCursorsToWholeLineIfNoToCursor(markdownContentLines, selection),
':::spoiler Toggle label\n',
'\n:::'
)
case FormatType.LINK:
return addLink(markdownContentLines, selection)
case FormatType.IMAGE_LINK:
return addLink(markdownContentLines, selection, '!')
default:
return markdownContentLines
}
}

View file

@ -0,0 +1,99 @@
/*
* 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: { line: 0, character: 0 } }, '')
expect(actual).toEqual(['[](https://)'])
})
it('inserts a link into a line', () => {
const actual = addLink(['aa'], { from: { line: 0, character: 1 } }, '')
expect(actual).toEqual(['a[](https://)a'])
})
it('inserts a link with a prefix', () => {
const actual = addLink([''], { from: { line: 0, character: 0 } }, 'prefix')
expect(actual).toEqual(['prefix[](https://)'])
})
})
describe('with a normal text selected', () => {
it('wraps the selection', () => {
const actual = addLink(
['a'],
{
from: { line: 0, character: 0 },
to: {
line: 0,
character: 1
}
},
''
)
expect(actual).toEqual(['[a](https://)'])
})
it('wraps the selection inside of a line', () => {
const actual = addLink(['aba'], { from: { line: 0, character: 1 }, to: { line: 0, character: 2 } }, '')
expect(actual).toEqual(['a[b](https://)a'])
})
it('wraps the selection with a prefix', () => {
const actual = addLink(['a'], { from: { line: 0, character: 0 }, to: { line: 0, character: 1 } }, 'prefix')
expect(actual).toEqual(['prefix[a](https://)'])
})
it('wraps a multi line selection', () => {
const actual = addLink(['a', 'b', 'c'], { from: { line: 0, character: 0 }, to: { line: 2, character: 1 } }, '')
expect(actual).toEqual(['[a', 'b', 'c](https://)'])
})
})
describe('with a url selected', () => {
it('wraps the selection', () => {
const actual = addLink(
['https://google.com'],
{
from: { line: 0, character: 0 },
to: {
line: 0,
character: 18
}
},
''
)
expect(actual).toEqual(['[](https://google.com)'])
})
it('wraps the selection with a prefix', () => {
const actual = addLink(
['https://google.com'],
{
from: { line: 0, character: 0 },
to: {
line: 0,
character: 18
}
},
'prefix'
)
expect(actual).toEqual(['prefix[](https://google.com)'])
})
it(`wraps a multi line selection not as link`, () => {
const actual = addLink(
['a', 'https://google.com', 'c'],
{ from: { line: 0, character: 0 }, to: { line: 2, character: 1 } },
''
)
expect(actual).toEqual(['[a', 'https://google.com', 'c](https://)'])
})
})
})

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { stringSplice } from './utils/string-splice'
import type { CursorSelection } from '../../../editor/types'
const beforeDescription = '['
const afterDescriptionBeforeLink = ']('
const defaultUrl = 'https://'
const afterLink = ')'
/**
* Creates a copy of the given markdown content lines but inserts a new link tag.
*
* @param markdownContentLines The lines of the document to modify
* @param selection If the selection has no to cursor then the tag will be inserted at this position.
* If the selection has a to cursor then the selected text will be inserted into the description or the URL part.
* @param prefix An optional prefix for the link
* @return the modified copy of lines
*/
export const addLink = (markdownContentLines: string[], selection: CursorSelection, prefix = ''): string[] => {
const from = selection.from
const to = selection.to ?? from
return markdownContentLines.map((currentLine, currentLineIndex) => {
if (from.line === to.line && currentLineIndex === from.line) {
const selectedText = markdownContentLines[from.line].slice(from.character, to.character)
const link = buildLink(selectedText, prefix)
return stringSplice(currentLine, from.character, link, selectedText.length)
} else if (currentLineIndex === from.line) {
return stringSplice(currentLine, from.character, beforeDescription)
} else if (currentLineIndex === to.line) {
return stringSplice(currentLine, to.character, afterDescriptionBeforeLink + defaultUrl + afterLink)
} else {
return currentLine
}
})
}
const buildLink = (selectedText: string, prefix: string): string => {
const linkRegex = /^(?:https?|mailto):/
if (linkRegex.test(selectedText)) {
return prefix + beforeDescription + afterDescriptionBeforeLink + selectedText + afterLink
} else {
return prefix + beforeDescription + selectedText + afterDescriptionBeforeLink + defaultUrl + afterLink
}
}

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { replaceLinesOfSelection } from './replace-lines-of-selection'
describe('replace lines of selection', () => {
it('replaces only the from-cursor line if no to-cursor is present', () => {
const actual = replaceLinesOfSelection(
['a', 'b', 'c'],
{
from: {
line: 1,
character: 123
}
},
(line, lineIndexInBlock) => `text_${line}_${lineIndexInBlock}`
)
expect(actual).toEqual(['a', 'text_b_0', 'c'])
})
it('replaces only one line if from-cursor and to-cursor are in the same line', () => {
const actual = replaceLinesOfSelection(
['a', 'b', 'c'],
{
from: {
line: 1,
character: 12
},
to: {
line: 1,
character: 34
}
},
(line, lineIndexInBlock) => `text_${line}_${lineIndexInBlock}`
)
expect(actual).toEqual(['a', 'text_b_0', 'c'])
})
it('replaces multiple lines', () => {
const actual = replaceLinesOfSelection(
['a', 'b', 'c', 'd', 'e'],
{
from: {
line: 1,
character: 1
},
to: {
line: 3,
character: 1
}
},
(line, lineIndexInBlock) => `text_${line}_${lineIndexInBlock}`
)
expect(actual).toEqual(['a', 'text_b_0', 'text_c_1', 'text_d_2', 'e'])
})
})

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CursorSelection } from '../../../editor/types'
/**
* 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 replaceLinesOfSelection = (
markdownContentLines: string[],
selection: CursorSelection,
replacer: (line: string, lineIndexInBlock: number) => string
): string[] => {
const toLineIndex = selection.to?.line ?? selection.from.line
return markdownContentLines.map((currentLine, currentLineIndex) => {
if (currentLineIndex < selection.from.line || currentLineIndex > toLineIndex) {
return currentLine
} else {
const lineIndexInBlock = currentLineIndex - selection.from.line
return replacer(currentLine, lineIndexInBlock)
}
})
}

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { replaceSelection } from './replace-selection'
describe('replace selection', () => {
it('inserts a text after the from-cursor if no to-cursor is present', () => {
const actual = replaceSelection(
['text1'],
{
from: {
line: 0,
character: 2
}
},
'text2'
)
expect(actual).toEqual(['tetext2xt1'])
})
it('inserts a text if from-cursor and to-cursor are the same', () => {
const actual = replaceSelection(
['text1'],
{
from: {
line: 0,
character: 2
},
to: {
line: 0,
character: 2
}
},
'text2'
)
expect(actual).toEqual(['tetext2xt1'])
})
it('replaces a single line text', () => {
const actual = replaceSelection(
['text1', 'text2', 'text3'],
{
from: {
line: 1,
character: 1
},
to: {
line: 1,
character: 2
}
},
'text4'
)
expect(actual).toEqual(['text1', 'ttext4xt2', 'text3'])
})
it('replaces a multi line text', () => {
const actual = replaceSelection(
['text1', 'text2', 'text3'],
{
from: {
line: 0,
character: 2
},
to: {
line: 2,
character: 3
}
},
'text4'
)
expect(actual).toEqual(['tetext4', 't3'])
})
})

View file

@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { stringSplice } from './utils/string-splice'
import type { CursorPosition, CursorSelection } from '../../../editor/types'
/**
* Creates a new {@link NoteDetails note state} but replaces the selected text.
*
* @param markdownContentLines The lines of the document to modify
* @param selection If the selection has no to cursor then text will only be inserted.
* If the selection has a to cursor then the selection will be replaced.
* @param insertText The text that should be inserted
* @return The modified state
*/
export const replaceSelection = (
markdownContentLines: string[],
selection: CursorSelection,
insertText: string
): string[] => {
const fromCursor = selection.from
const toCursor = selection.to ?? selection.from
const processLine = fromCursor.line === toCursor.line ? processSingleLineSelection : processMultiLineSelection
return markdownContentLines
.map((currentLine, currentLineIndex) =>
processLine(currentLine, currentLineIndex, insertText, fromCursor, toCursor)
)
.filter((currentLine, currentLineIndex) => filterLinesBetweenFromAndTo(currentLineIndex, fromCursor, toCursor))
}
/**
* Filters out every line that is between the from and the to cursor.
*
* @param currentLineIndex The index of the current line
* @param fromCursor The cursor position where the selection starts
* @param toCursor The cursor position where the selection ends
* @return {@code true} if the line should be present, {@code false} if it should be omitted.
*/
const filterLinesBetweenFromAndTo = (currentLineIndex: number, fromCursor: CursorPosition, toCursor: CursorPosition) =>
currentLineIndex <= fromCursor.line || currentLineIndex >= toCursor.line
/**
* Modifies a line if the selection is only in one line.
*
* @param line The current line content
* @param lineIndex The index of the current line in the document
* @param insertText The text to insert at the from cursor
* @param fromCursor The cursor position where the selection starts
* @param toCursor The cursor position where the selection ends
* @return the modified line if the current line index matches the line index in the from cursor position, the unmodified line otherwise.
*/
const processSingleLineSelection = (
line: string,
lineIndex: number,
insertText: string,
fromCursor: CursorPosition,
toCursor: CursorPosition
) => {
return lineIndex !== fromCursor.line
? line
: stringSplice(line, fromCursor.character, insertText, toCursor.character - fromCursor.character)
}
/**
* Modifies the start and the end line of a multi line selection by cutting the tail and head of these lines.
*
* @param line The current line content
* @param lineIndex The index of the current line in the document
* @param insertText The text to insert at the from cursor
* @param fromCursor The cursor position where the selection starts
* @param toCursor The cursor position where the selection ends
* @return The modified line if it's the line at the from/to cursor position. The lines between will be unmodified because a filter will take care of them.
*/
const processMultiLineSelection = (
line: string,
lineIndex: number,
insertText: string,
fromCursor: CursorPosition,
toCursor: CursorPosition
) => {
if (lineIndex === fromCursor.line) {
return line.slice(0, fromCursor.character) + insertText
} else if (lineIndex === toCursor.line) {
return line.slice(toCursor.character)
} else {
return line
}
}

View file

@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { changeCursorsToWholeLineIfNoToCursor } from './change-cursors-to-whole-line-if-no-to-cursor'
import type { CursorSelection } from '../../../../editor/types'
describe('changeCursorsToWholeLineIfNoToCursor', () => {
it(`returns the given selection if to cursor is present`, () => {
const givenSelection = {
from: {
line: 0,
character: 0
},
to: {
line: 0,
character: 0
}
}
expect(changeCursorsToWholeLineIfNoToCursor([], givenSelection)).toEqual(givenSelection)
})
it(`returns the corrected selection if to cursor isn't present and referred line does exist`, () => {
const givenSelection = {
from: {
line: 0,
character: 123
}
}
const expectedSelection: CursorSelection = {
from: {
line: 0,
character: 0
},
to: {
line: 0,
character: 27
}
}
expect(changeCursorsToWholeLineIfNoToCursor([`I'm a friendly test string!`], givenSelection)).toEqual(
expectedSelection
)
})
it(`fails if to cursor isn't present and referred line doesn't exist`, () => {
const givenSelection = {
from: {
line: 1,
character: 123
}
}
expect(() => changeCursorsToWholeLineIfNoToCursor([''], givenSelection)).toThrow()
})
})

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CursorSelection } from '../../../../editor/types'
import Optional from 'optional-js'
/**
* If the given cursor selection has no to position then the selection will be changed to cover the whole line of the from cursor.
*
* @param markdownContentLines The markdown content lines that are used to calculate the line length for the to cursor
* @param selection The selection to check
* @return The corrected selection if no to cursor is present or the unmodified selection otherwise
* @throws Error if the line, that the from cursor is referring to, doesn't exist.
*/
export const changeCursorsToWholeLineIfNoToCursor = (
markdownContentLines: string[],
selection: CursorSelection
): CursorSelection =>
selection.to !== undefined
? selection
: Optional.ofNullable(markdownContentLines[selection.from.line])
.map((line) => ({
from: {
line: selection.from.line,
character: 0
},
to: {
line: selection.from.line,
character: line.length
}
}))
.orElseThrow(() => new Error(`No line with index ${selection.from.line} found.`))

View file

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

View file

@ -0,0 +1,24 @@
/*
* 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)
}

View file

@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { wrapSelection } from './wrap-selection'
describe('wrap selection', () => {
it(`doesn't modify any line if no to-cursor is present`, () => {
const actual = wrapSelection(
['a', 'b', 'c'],
{
from: {
line: 0,
character: 0
}
},
'before',
'after'
)
expect(actual).toEqual(['a', 'b', 'c'])
})
it(`wraps the selected text in the same line`, () => {
const actual = wrapSelection(
['a', 'b', 'c'],
{
from: {
line: 0,
character: 0
},
to: {
line: 0,
character: 1
}
},
'before',
'after'
)
expect(actual).toEqual(['beforeaafter', 'b', 'c'])
})
it(`wraps the selected text in different lines`, () => {
const actual = wrapSelection(
['a', 'b', 'c'],
{
from: {
line: 0,
character: 0
},
to: {
line: 2,
character: 1
}
},
'before',
'after'
)
expect(actual).toEqual(['beforea', 'b', 'cafter'])
})
})

View file

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { stringSplice } from './utils/string-splice'
import type { CursorSelection } from '../../../editor/types'
/**
* Creates a copy of the given markdown content lines but wraps the selection.
*
* @param markdownContentLines The lines of the document to modify
* @param selection If the selection has no to cursor then nothing will happen.
* If the selection has a to cursor then the selected text will be wrapped.
* @param symbolStart A text that will be inserted before the from cursor
* @param symbolEnd A text that will be inserted after the to cursor
* @return the modified copy of lines
*/
export const wrapSelection = (
markdownContentLines: string[],
selection: CursorSelection,
symbolStart: string,
symbolEnd: string
): string[] => {
if (selection.to === undefined) {
return markdownContentLines
}
const to = selection.to ?? selection.from
const from = selection.from
return markdownContentLines.map((currentLine, currentLineIndex) => {
if (currentLineIndex === to.line) {
if (to.line === from.line) {
const moddedLine = stringSplice(currentLine, to.character, symbolEnd)
return stringSplice(moddedLine, from.character, symbolStart)
} else {
return stringSplice(currentLine, to.character, symbolEnd)
}
} else if (currentLineIndex === from.line) {
return stringSplice(currentLine, from.character, symbolStart)
} else {
return currentLine
}
})
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { generateNoteTitle } from './generate-note-title'
import { initialState } from './initial-state'
describe('generate note title', () => {
it('will choose the frontmatter title first', () => {
const actual = generateNoteTitle(
{ ...initialState.frontmatter, title: 'frontmatter', opengraph: { title: 'opengraph' } },
'first-heading'
)
expect(actual).toEqual('frontmatter')
})
it('will choose the opengraph title second', () => {
const actual = generateNoteTitle(
{ ...initialState.frontmatter, opengraph: { title: 'opengraph' } },
'first-heading'
)
expect(actual).toEqual('opengraph')
})
it('will choose the first heading third', () => {
const actual = generateNoteTitle({ ...initialState.frontmatter }, 'first-heading')
expect(actual).toEqual('first-heading')
})
})

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteFrontmatter } from './types/note-details'
/**
* Generates the note title from the given frontmatter or the first heading in the markdown content.
*
* @param frontmatter The frontmatter of the note
* @param firstHeading The first heading in the markdown content
* @return The title from the frontmatter or, if no title is present in the frontmatter, the first heading.
*/
export const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string): string => {
if (frontmatter?.title && frontmatter?.title !== '') {
return frontmatter.title.trim()
} else if (
frontmatter?.opengraph &&
frontmatter?.opengraph.title !== undefined &&
frontmatter?.opengraph.title !== ''
) {
return (frontmatter?.opengraph.title ?? firstHeading ?? '').trim()
} else {
return (firstHeading ?? '').trim()
}
}

View file

@ -20,6 +20,7 @@ export const initialSlideOptions: SlideOptions = {
export const initialState: NoteDetails = {
markdownContent: '',
markdownContentLines: [],
selection: { from: { line: 0, character: 0 } },
rawFrontmatter: '',
frontmatterRendererInfo: {
frontmatterInvalid: false,
@ -50,7 +51,7 @@ export const initialState: NoteDetails = {
GA: '',
disqus: '',
type: NoteType.DOCUMENT,
opengraph: new Map<string, string>(),
opengraph: {},
slideOptions: initialSlideOptions
}
}

View file

@ -7,13 +7,19 @@
import { store } from '..'
import type { NoteDto } from '../../api/notes/types'
import type {
AddTableAtCursorAction,
FormatSelectionAction,
FormatType,
InsertTextAtCursorAction,
ReplaceInMarkdownContentAction,
SetNoteDetailsFromServerAction,
SetNoteDocumentContentAction,
UpdateCursorPositionAction,
UpdateNoteTitleByFirstHeadingAction,
UpdateTaskListCheckboxAction
} from './types'
import { NoteDetailsActionType } from './types'
import type { CursorPosition, CursorSelection } from '../editor/types'
/**
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
@ -75,3 +81,56 @@ export const replaceInMarkdownContent = (replaceable: string, replacement: strin
replacement
} as ReplaceInMarkdownContentAction)
}
export const updateCursorPositions = (selection: CursorSelection): void => {
const correctedSelection: CursorSelection = isFromAfterTo(selection)
? {
to: selection.from,
from: selection.to as CursorPosition
}
: selection
store.dispatch({
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION,
selection: correctedSelection
} as UpdateCursorPositionAction)
}
/**
* Checks if the from cursor position in the given selection is after the to cursor position.
*
* @param selection The cursor selection to check
* @return {@code true} if the from cursor position is after the to position
*/
const isFromAfterTo = (selection: CursorSelection): boolean => {
if (selection.to === undefined) {
return false
}
if (selection.from.line < selection.to.line) {
return false
}
return selection.from.line !== selection.to.line || selection.from.character > selection.to.character
}
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)
}

View file

@ -45,14 +45,14 @@ describe('yaml frontmatter', () => {
it('should parse an empty opengraph object', () => {
const noteFrontmatter = createNoteFrontmatterFromYaml('opengraph:')
expect(noteFrontmatter.opengraph).toEqual(new Map<string, string>())
expect(noteFrontmatter.opengraph).toEqual({})
})
it('should parse an opengraph title', () => {
const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph:
title: Testtitle
`)
expect(noteFrontmatter.opengraph.get('title')).toEqual('Testtitle')
expect(noteFrontmatter.opengraph.title).toEqual('Testtitle')
})
it('should parse multiple opengraph values', () => {
@ -61,8 +61,8 @@ describe('yaml frontmatter', () => {
image: https://dummyimage.com/48.png
image:type: image/png
`)
expect(noteFrontmatter.opengraph.get('title')).toEqual('Testtitle')
expect(noteFrontmatter.opengraph.get('image')).toEqual('https://dummyimage.com/48.png')
expect(noteFrontmatter.opengraph.get('image:type')).toEqual('image/png')
expect(noteFrontmatter.opengraph.title).toEqual('Testtitle')
expect(noteFrontmatter.opengraph.image).toEqual('https://dummyimage.com/48.png')
expect(noteFrontmatter.opengraph['image:type']).toEqual('image/png')
})
})

View file

@ -6,11 +6,11 @@
import { load } from 'js-yaml'
import type { SlideOptions } from '../types/slide-show-options'
import type { NoteFrontmatter } from '../types/note-details'
import type { Iso6391Language, NoteFrontmatter, OpenGraph } from '../types/note-details'
import { NoteTextDirection, NoteType } from '../types/note-details'
import { ISO6391 } from '../types/iso6391'
import type { RawNoteFrontmatter } from './types'
import { initialSlideOptions } from '../initial-state'
import { initialSlideOptions, initialState } from '../initial-state'
/**
* Creates a new frontmatter metadata instance based on a raw yaml string.
@ -37,30 +37,75 @@ const parseRawNoteFrontmatter = (rawData: RawNoteFrontmatter): NoteFrontmatter =
tags = rawData?.tags?.filter((tag) => tag !== null) ?? []
deprecatedTagsSyntax = false
} else {
tags = []
tags = [...initialState.frontmatter.tags]
deprecatedTagsSyntax = false
}
return {
title: rawData.title ?? '',
description: rawData.description ?? '',
robots: rawData.robots ?? '',
newlinesAreBreaks: rawData.breaks ?? true,
GA: rawData.GA ?? '',
disqus: rawData.disqus ?? '',
lang: (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? 'en',
type: rawData.type === NoteType.SLIDE ? NoteType.SLIDE : NoteType.DOCUMENT,
dir: rawData.dir === NoteTextDirection.LTR ? NoteTextDirection.LTR : NoteTextDirection.RTL,
opengraph: rawData?.opengraph
? new Map<string, string>(Object.entries(rawData.opengraph))
: new Map<string, string>(),
title: rawData.title ?? initialState.frontmatter.title,
description: rawData.description ?? initialState.frontmatter.description,
robots: rawData.robots ?? initialState.frontmatter.robots,
newlinesAreBreaks: rawData.breaks ?? initialState.frontmatter.newlinesAreBreaks,
GA: rawData.GA ?? initialState.frontmatter.GA,
disqus: rawData.disqus ?? initialState.frontmatter.disqus,
lang: parseLanguage(rawData),
type: parseNoteType(rawData),
dir: parseTextDirection(rawData),
opengraph: parseOpenGraph(rawData),
slideOptions: parseSlideOptions(rawData),
tags,
deprecatedTagsSyntax
}
}
/**
* Parses the {@link OpenGraph open graph} from the {@link RawNoteFrontmatter}.
*
* @param rawData The raw note frontmatter data.
* @return the parsed {@link OpenGraph open graph}
*/
const parseOpenGraph = (rawData: RawNoteFrontmatter): OpenGraph => {
return { ...(rawData.opengraph ?? initialState.frontmatter.opengraph) }
}
/**
* Parses the {@link Iso6391Language iso 6391 language code} from the {@link RawNoteFrontmatter}.
*
* @param rawData The raw note frontmatter data.
* @return the parsed {@link Iso6391Language iso 6391 language code}
*/
const parseLanguage = (rawData: RawNoteFrontmatter): Iso6391Language => {
return (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? initialState.frontmatter.lang
}
/**
* Parses the {@link NoteType note type} from the {@link RawNoteFrontmatter}.
*
* @param rawData The raw note frontmatter data.
* @return the parsed {@link NoteType note type}
*/
const parseNoteType = (rawData: RawNoteFrontmatter): NoteType => {
return rawData.type !== undefined
? rawData.type === NoteType.SLIDE
? NoteType.SLIDE
: NoteType.DOCUMENT
: initialState.frontmatter.type
}
/**
* Parses the {@link NoteTextDirection note text direction} from the {@link RawNoteFrontmatter}.
*
* @param rawData The raw note frontmatter data.
* @return the parsed {@link NoteTextDirection note text direction}
*/
const parseTextDirection = (rawData: RawNoteFrontmatter): NoteTextDirection => {
return rawData.dir !== undefined
? rawData.dir === NoteTextDirection.LTR
? NoteTextDirection.LTR
: NoteTextDirection.RTL
: initialState.frontmatter.dir
}
/**
* Parses the {@link SlideOptions} from the {@link RawNoteFrontmatter}.
*

View file

@ -5,23 +5,29 @@
*/
import type { Reducer } from 'redux'
import { createNoteFrontmatterFromYaml } from './raw-note-frontmatter-parser/parser'
import type { NoteDetailsActions } from './types'
import { NoteDetailsActionType } from './types'
import { extractFrontmatter } from './frontmatter-extractor/extractor'
import type { NoteDto } from '../../api/notes/types'
import { initialState } from './initial-state'
import { DateTime } from 'luxon'
import type { NoteDetails, NoteFrontmatter } from './types/note-details'
import type { PresentFrontmatterExtractionResult } from './frontmatter-extractor/types'
import type { NoteDetails } from './types/note-details'
import { buildStateFromUpdatedMarkdownContent } from './build-state-from-updated-markdown-content'
import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-update-cursor-position'
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
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'
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
state: NoteDetails = initialState,
action: NoteDetailsActions
) => {
switch (action.type) {
case NoteDetailsActionType.UPDATE_CURSOR_POSITION:
return buildStateFromUpdateCursorPosition(state, action.selection)
case NoteDetailsActionType.SET_DOCUMENT_CONTENT:
return buildStateFromMarkdownContentUpdate(state, action.content)
return buildStateFromUpdatedMarkdownContent(state, action.content)
case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING:
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
@ -29,187 +35,14 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX:
return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked)
case NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT:
return buildStateFromDocumentContentReplacement(state, action.placeholder, action.replacement)
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:
return state
}
}
/**
* 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
*/
const buildStateFromDocumentContentReplacement = (
state: NoteDetails,
replaceable: string,
replacement: string
): NoteDetails => {
return buildStateFromMarkdownContentUpdate(state, state.markdownContent.replaceAll(replaceable, replacement))
}
/**
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
* @param dto The first DTO received from the API containing the relevant information about the note.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromServerDto = (dto: NoteDto): NoteDetails => {
const newState = convertNoteDtoToNoteDetails(dto)
return buildStateFromMarkdownContentUpdate(newState, newState.markdownContent)
}
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]])( .*)/
/**
* Builds a {@link NoteDetails} redux state where a checkbox in the markdown content either gets checked or unchecked.
* @param state The previous redux state.
* @param changedLine The number of the line in which the checkbox should be updated.
* @param checkboxChecked true if the checkbox should be checked, false otherwise.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromTaskListUpdate = (
state: NoteDetails,
changedLine: number,
checkboxChecked: boolean
): NoteDetails => {
const lines = state.markdownContentLines
const results = TASK_REGEX.exec(lines[changedLine])
if (results) {
const before = results[1]
const after = results[3]
lines[changedLine] = `${before}[${checkboxChecked ? 'x' : ' '}]${after}`
return buildStateFromMarkdownContentUpdate(state, lines.join('\n'))
}
return state
}
/**
* Builds a {@link NoteDetails} redux state from a fresh document content.
* @param state The previous redux state.
* @param newMarkdownContent The fresh document content consisting of the frontmatter and markdown part.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromMarkdownContentUpdate = (state: NoteDetails, newMarkdownContent: string): NoteDetails => {
const markdownContentLines = newMarkdownContent.split('\n')
const frontmatterExtraction = extractFrontmatter(markdownContentLines)
if (frontmatterExtraction.isPresent) {
return buildStateFromFrontmatterUpdate(
{
...state,
markdownContent: newMarkdownContent,
markdownContentLines: markdownContentLines
},
frontmatterExtraction
)
} else {
return {
...state,
markdownContent: newMarkdownContent,
markdownContentLines: markdownContentLines,
rawFrontmatter: '',
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
frontmatter: initialState.frontmatter,
frontmatterRendererInfo: initialState.frontmatterRendererInfo
}
}
}
/**
* Builds a {@link NoteDetails} redux state from extracted frontmatter data.
* @param state The previous redux state.
* @param frontmatterExtraction The result of the frontmatter extraction containing the raw data and the line offset.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromFrontmatterUpdate = (
state: NoteDetails,
frontmatterExtraction: PresentFrontmatterExtractionResult
): NoteDetails => {
if (frontmatterExtraction.rawText === state.rawFrontmatter) {
return state
}
try {
const frontmatter = createNoteFrontmatterFromYaml(frontmatterExtraction.rawText)
return {
...state,
rawFrontmatter: frontmatterExtraction.rawText,
frontmatter: frontmatter,
noteTitle: generateNoteTitle(frontmatter, state.firstHeading),
frontmatterRendererInfo: {
lineOffset: frontmatterExtraction.lineOffset,
deprecatedSyntax: frontmatter.deprecatedTagsSyntax,
frontmatterInvalid: false,
slideOptions: frontmatter.slideOptions
}
}
} catch (e) {
return {
...state,
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
rawFrontmatter: frontmatterExtraction.rawText,
frontmatter: initialState.frontmatter,
frontmatterRendererInfo: {
lineOffset: frontmatterExtraction.lineOffset,
deprecatedSyntax: false,
frontmatterInvalid: true,
slideOptions: initialState.frontmatterRendererInfo.slideOptions
}
}
}
}
/**
* Builds a {@link NoteDetails} redux state with an updated note title from frontmatter data and the first heading.
* @param state The previous redux state.
* @param firstHeading The first heading of the document. Should be {@code undefined} if there is no such heading.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeading?: string): NoteDetails => {
return {
...state,
firstHeading: firstHeading,
noteTitle: generateNoteTitle(state.frontmatter, firstHeading)
}
}
const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string) => {
if (frontmatter?.title && frontmatter?.title !== '') {
return frontmatter.title.trim()
} else if (
frontmatter?.opengraph &&
frontmatter?.opengraph.get('title') &&
frontmatter?.opengraph.get('title') !== ''
) {
return (frontmatter?.opengraph.get('title') ?? firstHeading ?? '').trim()
} else {
return (firstHeading ?? '').trim()
}
}
/**
* Converts a note DTO from the HTTP API to a {@link NoteDetails} object.
* Note that the documentContent will be set but the markdownContent and rawFrontmatterContent are yet to be processed.
* @param note The NoteDTO as defined in the backend.
* @return The NoteDetails object corresponding to the DTO.
*/
const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => {
return {
markdownContent: note.content,
markdownContentLines: note.content.split('\n'),
rawFrontmatter: '',
frontmatterRendererInfo: initialState.frontmatterRendererInfo,
frontmatter: initialState.frontmatter,
id: note.metadata.id,
noteTitle: initialState.noteTitle,
createTime: DateTime.fromISO(note.metadata.createTime),
lastChange: {
username: note.metadata.updateUser.username,
timestamp: DateTime.fromISO(note.metadata.updateTime)
},
firstHeading: initialState.firstHeading,
viewCount: note.metadata.viewCount,
alias: note.metadata.alias,
authorship: note.metadata.editedBy
}
}

View file

@ -0,0 +1,60 @@
/*
* 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,
markdownContentLines: ['a', 'b', 'c'],
markdownContent: 'a\nb\nc',
selection: {
from: {
line: 1,
character: 0
}
}
},
3,
3
)
expect(actual.markdownContent).toEqual(
'a\n\n| # 1 | # 2 | # 3 |\n' +
'| ---- | ---- | ---- |\n' +
'| Text | Text | Text |\n' +
'| Text | Text | Text |\n' +
'| Text | Text | Text |b\n' +
'c'
)
})
})

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteDetails } from '../types/note-details'
import { buildStateFromUpdatedMarkdownContentLines } 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)
return buildStateFromUpdatedMarkdownContentLines(
state,
replaceSelection(state.markdownContentLines, { from: state.selection.to ?? state.selection.from }, table)
)
}
/**
* 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}`
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as generateNoteTitleModule from '../generate-note-title'
import { buildStateFromFirstHeadingUpdate } from './build-state-from-first-heading-update'
import { initialState } from '../initial-state'
describe('build state from first heading update', () => {
const generateNoteTitleMock = jest.spyOn(generateNoteTitleModule, 'generateNoteTitle')
beforeAll(() => {
generateNoteTitleMock.mockImplementation(() => 'generated title')
})
afterAll(() => {
generateNoteTitleMock.mockReset()
})
it('generates a new state with the given first heading', () => {
const startState = { ...initialState, firstHeading: 'heading', noteTitle: 'noteTitle' }
const actual = buildStateFromFirstHeadingUpdate(startState, 'new first heading')
expect(actual).toStrictEqual({ ...initialState, firstHeading: 'new first heading', noteTitle: 'generated title' })
})
})

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteDetails } from '../types/note-details'
import { generateNoteTitle } from '../generate-note-title'
/**
* Builds a {@link NoteDetails} redux state with an updated note title from frontmatter data and the first heading.
* @param state The previous redux state.
* @param firstHeading The first heading of the document. Should be {@code undefined} if there is no such heading.
* @return An updated {@link NoteDetails} redux state.
*/
export const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeading?: string): NoteDetails => {
return {
...state,
firstHeading: firstHeading,
noteTitle: generateNoteTitle(state.frontmatter, firstHeading)
}
}

View file

@ -0,0 +1,34 @@
/*
* 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 = { ...initialState, markdownContent: 'replaceable' }
const result = buildStateFromReplaceInMarkdownContent(startState, 'replaceable', 'replacement')
expect(result).toBe(mockedNoteDetails)
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, 'replacement')
})
})

View file

@ -0,0 +1,39 @@
/*
* 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, replaceable, replacement))
}

View file

@ -0,0 +1,59 @@
/*
* 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 buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn(
buildStateFromUpdatedMarkdownContentLinesModule,
'buildStateFromUpdatedMarkdownContentLines'
)
const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection')
const mockedNoteDetails = Mock.of<NoteDetails>()
const mockedReplacedLines = ['replaced']
beforeAll(() => {
buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails)
replaceSelectionMock.mockImplementation(() => mockedReplacedLines)
})
afterAll(() => {
buildStateFromUpdatedMarkdownContentLinesMock.mockReset()
replaceSelectionMock.mockReset()
})
it('builds a new state with the provided cursor', () => {
const originalLines = ['original']
const startState = { ...initialState, markdownContentLines: originalLines }
const customCursor = Mock.of<CursorSelection>()
const textReplacement = 'replacement'
const result = buildStateFromReplaceSelection(startState, 'replacement', customCursor)
expect(result).toBe(mockedNoteDetails)
expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedReplacedLines)
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 = { ...initialState, markdownContentLines: originalLines, selection }
const textReplacement = 'replacement'
const result = buildStateFromReplaceSelection(startState, 'replacement')
expect(result).toBe(mockedNoteDetails)
expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedReplacedLines)
expect(replaceSelectionMock).toHaveBeenCalledWith(originalLines, selection, textReplacement)
})
})

View file

@ -0,0 +1,17 @@
/*
* 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 { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content'
import { replaceSelection } from '../format-selection/formatters/replace-selection'
export const buildStateFromReplaceSelection = (state: NoteDetails, text: string, cursorSelection?: CursorSelection) => {
return buildStateFromUpdatedMarkdownContentLines(
state,
replaceSelection(state.markdownContentLines, cursorSelection ? cursorSelection : state.selection, text)
)
}

View file

@ -0,0 +1,47 @@
/*
* 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 buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn(
buildStateFromUpdatedMarkdownContentLinesModule,
'buildStateFromUpdatedMarkdownContentLines'
)
const mockedNoteDetails = Mock.of<NoteDetails>()
const applyFormatTypeToMarkdownLinesMock = jest.spyOn(
applyFormatTypeToMarkdownLinesModule,
'applyFormatTypeToMarkdownLines'
)
const mockedFormattedLines = ['formatted']
beforeAll(() => {
buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails)
applyFormatTypeToMarkdownLinesMock.mockImplementation(() => mockedFormattedLines)
})
afterAll(() => {
buildStateFromUpdatedMarkdownContentLinesMock.mockReset()
applyFormatTypeToMarkdownLinesMock.mockReset()
})
it('builds a new state with the formatted code', () => {
const originalLines = ['original']
const customCursor = Mock.of<CursorSelection>()
const startState = { ...initialState, markdownContentLines: originalLines, selection: customCursor }
const result = buildStateFromSelectionFormat(startState, FormatType.BOLD)
expect(result).toBe(mockedNoteDetails)
expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedFormattedLines)
expect(applyFormatTypeToMarkdownLinesMock).toHaveBeenCalledWith(originalLines, customCursor, FormatType.BOLD)
})
})

View file

@ -0,0 +1,17 @@
/*
* 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 { buildStateFromUpdatedMarkdownContentLines } 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 => {
return buildStateFromUpdatedMarkdownContentLines(
state,
applyFormatTypeToMarkdownLines(state.markdownContentLines, state.selection, type)
)
}

View file

@ -0,0 +1,142 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteDto } from '../../../api/notes/types'
import { buildStateFromServerDto } from './build-state-from-set-note-data-from-server'
import * as buildStateFromUpdatedMarkdownContentModule from '../build-state-from-updated-markdown-content'
import { Mock } from 'ts-mockery'
import type { NoteDetails } from '../types/note-details'
import { NoteTextDirection, NoteType } from '../types/note-details'
import { DateTime } from 'luxon'
import { initialSlideOptions } from '../initial-state'
describe('build state from set note data from server', () => {
const buildStateFromUpdatedMarkdownContentMock = jest.spyOn(
buildStateFromUpdatedMarkdownContentModule,
'buildStateFromUpdatedMarkdownContent'
)
const mockedNoteDetails = Mock.of<NoteDetails>()
beforeAll(() => {
buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails)
})
afterAll(() => {
buildStateFromUpdatedMarkdownContentMock.mockReset()
})
it('builds a new state from the given note dto', () => {
const noteDto: NoteDto = {
content: 'line1\nline2',
metadata: {
version: 5678,
alias: 'alias',
id: 'id',
createTime: '2012-05-25T09:08:34.123',
description: 'description',
editedBy: ['editedBy'],
permissions: {
owner: {
username: 'username',
photo: 'photo',
email: 'email',
displayName: 'displayName'
},
sharedToGroups: [
{
canEdit: true,
group: {
displayName: 'groupdisplayname',
name: 'groupname',
special: true
}
}
],
sharedToUsers: [
{
canEdit: true,
user: {
username: 'shareusername',
email: 'shareemail',
photo: 'sharephoto',
displayName: 'sharedisplayname'
}
}
]
},
viewCount: 987,
tags: ['tag'],
title: 'title',
updateTime: '2020-05-25T09:08:34.123',
updateUser: {
username: 'updateusername',
photo: 'updatephoto',
email: 'updateemail',
displayName: 'updatedisplayname'
}
},
editedByAtPosition: [
{
endPos: 5,
createdAt: 'createdAt',
startPos: 9,
updatedAt: 'updatedAt',
userName: 'userName'
}
]
}
const convertedNoteDetails: NoteDetails = {
frontmatter: {
title: '',
description: '',
tags: [],
deprecatedTagsSyntax: false,
robots: '',
lang: 'en',
dir: NoteTextDirection.LTR,
newlinesAreBreaks: true,
GA: '',
disqus: '',
type: NoteType.DOCUMENT,
opengraph: {},
slideOptions: {
transition: 'zoom',
autoSlide: 0,
autoSlideStoppable: true,
backgroundTransition: 'fade',
slideNumber: false
}
},
frontmatterRendererInfo: {
frontmatterInvalid: false,
deprecatedSyntax: false,
lineOffset: 0,
slideOptions: initialSlideOptions
},
noteTitle: '',
selection: { from: { line: 0, character: 0 } },
markdownContent: 'line1\nline2',
markdownContentLines: ['line1', 'line2'],
firstHeading: '',
rawFrontmatter: '',
id: 'id',
createTime: DateTime.fromISO('2012-05-25T09:08:34.123'),
lastChange: {
username: 'updateusername',
timestamp: DateTime.fromISO('2020-05-25T09:08:34.123')
},
viewCount: 987,
alias: 'alias',
authorship: ['editedBy']
}
const result = buildStateFromServerDto(noteDto)
expect(result).toEqual(mockedNoteDetails)
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(convertedNoteDetails, 'line1\nline2')
})
})

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteDto } from '../../../api/notes/types'
import type { NoteDetails } from '../types/note-details'
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
import { initialState } from '../initial-state'
import { DateTime } from 'luxon'
/**
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
* @param dto The first DTO received from the API containing the relevant information about the note.
* @return An updated {@link NoteDetails} redux state.
*/
export const buildStateFromServerDto = (dto: NoteDto): NoteDetails => {
const newState = convertNoteDtoToNoteDetails(dto)
return buildStateFromUpdatedMarkdownContent(newState, newState.markdownContent)
}
/**
* Converts a note DTO from the HTTP API to a {@link NoteDetails} object.
* Note that the documentContent will be set but the markdownContent and rawFrontmatterContent are yet to be processed.
* @param note The NoteDTO as defined in the backend.
* @return The NoteDetails object corresponding to the DTO.
*/
const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => {
return {
...initialState,
markdownContent: note.content,
markdownContentLines: note.content.split('\n'),
rawFrontmatter: '',
id: note.metadata.id,
createTime: DateTime.fromISO(note.metadata.createTime),
lastChange: {
username: note.metadata.updateUser.username,
timestamp: DateTime.fromISO(note.metadata.updateTime)
},
viewCount: note.metadata.viewCount,
alias: note.metadata.alias,
authorship: note.metadata.editedBy
}
}

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { initialState } from '../initial-state'
import * as buildStateFromUpdatedMarkdownContentLinesModule from '../build-state-from-updated-markdown-content'
import { Mock } from 'ts-mockery'
import type { NoteDetails } from '../types/note-details'
import { buildStateFromTaskListUpdate } from './build-state-from-task-list-update'
describe('build state from task list update', () => {
const buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn(
buildStateFromUpdatedMarkdownContentLinesModule,
'buildStateFromUpdatedMarkdownContentLines'
)
const mockedNoteDetails = Mock.of<NoteDetails>()
beforeAll(() => {
buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails)
})
afterAll(() => {
buildStateFromUpdatedMarkdownContentLinesMock.mockReset()
})
const markdownContentLines = ['no task', '- [ ] not checked', '- [x] checked']
it(`doesn't change the state if the line doesn't contain a task`, () => {
const startState = { ...initialState, markdownContentLines: markdownContentLines }
const result = buildStateFromTaskListUpdate(startState, 0, true)
expect(result).toBe(startState)
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledTimes(0)
})
it(`can change the state of a task to checked`, () => {
const startState = { ...initialState, markdownContentLines: markdownContentLines }
const result = buildStateFromTaskListUpdate(startState, 1, true)
expect(result).toBe(mockedNoteDetails)
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [
'no task',
'- [x] not checked',
'- [x] checked'
])
})
it(`can change the state of a task to unchecked`, () => {
const startState = { ...initialState, markdownContentLines: markdownContentLines }
const result = buildStateFromTaskListUpdate(startState, 2, false)
expect(result).toBe(mockedNoteDetails)
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [
'no task',
'- [ ] not checked',
'- [ ] checked'
])
})
})

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteDetails } from '../types/note-details'
import Optional from 'optional-js'
import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content'
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )\[[ xX]?]( .*)/
/**
* Builds a {@link NoteDetails} redux state where a checkbox in the markdown content either gets checked or unchecked.
* @param state The previous redux state.
* @param changedLineIndex The number of the line in which the checkbox should be updated.
* @param checkboxChecked true if the checkbox should be checked, false otherwise.
* @return An updated {@link NoteDetails} redux state.
*/
export const buildStateFromTaskListUpdate = (
state: NoteDetails,
changedLineIndex: number,
checkboxChecked: boolean
): NoteDetails => {
const lines = [...state.markdownContentLines]
return Optional.ofNullable(TASK_REGEX.exec(lines[changedLineIndex]))
.map((results) => {
const [, beforeCheckbox, afterCheckbox] = results
lines[changedLineIndex] = `${beforeCheckbox}[${checkboxChecked ? 'x' : ' '}]${afterCheckbox}`
return buildStateFromUpdatedMarkdownContentLines(state, lines)
})
.orElse(state)
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { initialState } from '../initial-state'
import type { CursorSelection } from '../../editor/types'
import { Mock } from 'ts-mockery'
import { buildStateFromUpdateCursorPosition } from './build-state-from-update-cursor-position'
describe('build state from update cursor position', () => {
it('creates a new state with the given cursor', () => {
const state = { ...initialState }
const selection: CursorSelection = Mock.of<CursorSelection>()
expect(buildStateFromUpdateCursorPosition(state, selection)).toStrictEqual({ ...state, selection })
})
})

View file

@ -0,0 +1,15 @@
/*
* 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'
export const buildStateFromUpdateCursorPosition = (state: NoteDetails, selection: CursorSelection): NoteDetails => {
return {
...state,
selection
}
}

View file

@ -6,13 +6,39 @@
import type { Action } from 'redux'
import type { NoteDto } from '../../api/notes/types'
import type { CursorSelection } from '../editor/types'
export enum NoteDetailsActionType {
SET_DOCUMENT_CONTENT = 'note-details/content/set',
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox',
REPLACE_IN_MARKDOWN_CONTENT = 'note-details/replace-in-markdown-content'
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 =
@ -20,7 +46,11 @@ export type NoteDetailsActions =
| SetNoteDetailsFromServerAction
| UpdateNoteTitleByFirstHeadingAction
| UpdateTaskListCheckboxAction
| UpdateCursorPositionAction
| ReplaceInMarkdownContentAction
| FormatSelectionAction
| AddTableAtCursorAction
| InsertTextAtCursorAction
/**
* Action for updating the document content of the currently loaded note.
@ -60,3 +90,25 @@ export interface ReplaceInMarkdownContentAction extends Action<NoteDetailsAction
placeholder: string
replacement: string
}
export interface UpdateCursorPositionAction extends Action<NoteDetailsActionType> {
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION
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
}

View file

@ -7,6 +7,7 @@
import type { DateTime } from 'luxon'
import type { SlideOptions } from './slide-show-options'
import type { ISO6391 } from './iso6391'
import type { CursorSelection } from '../../editor/types'
/**
* Redux state containing the currently loaded note with its content and metadata.
@ -14,6 +15,7 @@ import type { ISO6391 } from './iso6391'
export interface NoteDetails {
markdownContent: string
markdownContentLines: string[]
selection: CursorSelection
rawFrontmatter: string
frontmatter: NoteFrontmatter
frontmatterRendererInfo: RendererFrontmatterInfo
@ -30,19 +32,23 @@ export interface NoteDetails {
firstHeading?: string
}
export type Iso6391Language = typeof ISO6391[number]
export type OpenGraph = Record<string, string>
export interface NoteFrontmatter {
title: string
description: string
tags: string[]
deprecatedTagsSyntax: boolean
robots: string
lang: typeof ISO6391[number]
lang: Iso6391Language
dir: NoteTextDirection
newlinesAreBreaks: boolean
GA: string
disqus: string
type: NoteType
opengraph: Map<string, string>
opengraph: OpenGraph
slideOptions: SlideOptions
}