From 04e16d8880386266b72bd7a97b4fa2d2547d1d47 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Thu, 2 Sep 2021 11:15:31 +0200 Subject: [PATCH] Move frontmatter extraction from renderer to redux (#1413) --- package.json | 1 - src/api/notes/dto-methods.ts | 28 --- .../extract-frontmatter.test.ts | 94 +++++++++ .../note-frontmatter/extract-frontmatter.ts | 40 ++++ .../note-frontmatter/note-frontmatter.test.ts | 68 ++++++ .../note-frontmatter/note-frontmatter.ts | 70 +++++++ .../note-frontmatter/types.ts} | 67 ++---- .../document-read-only-page.tsx | 4 +- .../editor-page/app-bar/app-bar.tsx | 2 +- .../document-bar/share/share-modal.tsx | 2 +- src/components/editor-page/editor-page.tsx | 11 +- .../note-frontmatter/note-frontmatter.test.ts | 189 ----------------- .../renderer-pane/render-iframe.tsx | 13 +- .../yaml-array-deprecation-alert.tsx | 7 +- .../sidebar/export-markdown-sidebar-entry.tsx | 8 +- .../sidebar/import-markdown-sidebar-entry.tsx | 4 +- .../basic-markdown-renderer.tsx | 50 +---- .../hooks/use-component-replacers.ts | 8 +- .../BasicMarkdownItConfigurator.tsx | 16 +- .../markdown-it-plugins/frontmatter.ts | 30 --- .../linemarker/line-number-marker.ts | 13 +- .../task-list/task-list-replacer.tsx | 6 +- .../iframe-editor-to-renderer-communicator.ts | 18 +- .../render-page/iframe-markdown-renderer.tsx | 19 +- .../iframe-renderer-to-editor-communicator.ts | 17 +- .../render-page/markdown-document.tsx | 14 +- .../render-page/rendering-message.ts | 14 +- src/redux/index.ts | 2 +- src/redux/note-details/initial-state.ts | 45 ++++ src/redux/note-details/methods.ts | 54 ++--- src/redux/note-details/reducer.ts | 194 ++++++++++++++++++ src/redux/note-details/reducers.ts | 105 ---------- src/redux/note-details/types.ts | 51 +++-- yarn.lock | 5 - 34 files changed, 680 insertions(+), 589 deletions(-) delete mode 100644 src/api/notes/dto-methods.ts create mode 100644 src/components/common/note-frontmatter/extract-frontmatter.test.ts create mode 100644 src/components/common/note-frontmatter/extract-frontmatter.ts create mode 100644 src/components/common/note-frontmatter/note-frontmatter.test.ts create mode 100644 src/components/common/note-frontmatter/note-frontmatter.ts rename src/components/{editor-page/note-frontmatter/note-frontmatter.ts => common/note-frontmatter/types.ts} (59%) delete mode 100644 src/components/editor-page/note-frontmatter/note-frontmatter.test.ts delete mode 100644 src/components/markdown-renderer/markdown-it-plugins/frontmatter.ts create mode 100644 src/redux/note-details/initial-state.ts create mode 100644 src/redux/note-details/reducer.ts delete mode 100644 src/redux/note-details/reducers.ts diff --git a/package.json b/package.json index f1579ab8e..7dbec0625 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "markdown-it-deflist": "2.1.0", "markdown-it-emoji": "2.0.0", "markdown-it-footnote": "3.0.3", - "markdown-it-front-matter": "0.2.3", "markdown-it-imsize": "2.0.1", "markdown-it-ins": "3.0.1", "markdown-it-mark": "3.0.1", diff --git a/src/api/notes/dto-methods.ts b/src/api/notes/dto-methods.ts deleted file mode 100644 index 4602e4e7e..000000000 --- a/src/api/notes/dto-methods.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { NoteDto } from './types' -import { NoteDetails } from '../../redux/note-details/types' -import { DateTime } from 'luxon' -import { initialState } from '../../redux/note-details/reducers' - -export const noteDtoToNoteDetails = (note: NoteDto): NoteDetails => { - return { - markdownContent: note.content, - 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 - } -} diff --git a/src/components/common/note-frontmatter/extract-frontmatter.test.ts b/src/components/common/note-frontmatter/extract-frontmatter.test.ts new file mode 100644 index 000000000..bcf4ab43a --- /dev/null +++ b/src/components/common/note-frontmatter/extract-frontmatter.test.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { extractFrontmatter } from './extract-frontmatter' +import { PresentFrontmatterExtractionResult } from './types' + +describe('frontmatter extraction', () => { + describe('frontmatterPresent property', () => { + it('is false when note does not contain three dashes at all', () => { + const testNote = 'abcdef\nmore text' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(false) + }) + it('is false when note does not start with three dashes', () => { + const testNote = '\n---\nthis is not frontmatter' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(false) + }) + it('is false when note start with less than three dashes', () => { + const testNote = '--\nthis is not frontmatter' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(false) + }) + it('is false when note starts with three dashes but contains other characters in the same line', () => { + const testNote = '--- a\nthis is not frontmatter' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(false) + }) + it('is false when note has no ending marker for frontmatter', () => { + const testNote = '---\nthis is not frontmatter\nbecause\nthere is no\nend marker' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(false) + }) + it('is false when note end marker is present but with not the same amount of dashes as start marker', () => { + const testNote = '---\nthis is not frontmatter\n----\ncontent' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(false) + }) + it('is true when note end marker is present with the same amount of dashes as start marker', () => { + const testNote = '---\nthis is frontmatter\n---\ncontent' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(true) + }) + it('is true when note end marker is present with the same amount of dashes as start marker but without content', () => { + const testNote = '---\nthis is frontmatter\n---' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(true) + }) + it('is true when note end marker is present with the same amount of dots as start marker', () => { + const testNote = '---\nthis is frontmatter\n...\ncontent' + const extraction = extractFrontmatter(testNote) + expect(extraction.frontmatterPresent).toBe(true) + }) + }) + + describe('frontmatterLines property', () => { + it('is correct for single line frontmatter without content', () => { + const testNote = '---\nsingle line frontmatter\n...' + const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult + expect(extraction.frontmatterLines).toEqual(3) + }) + it('is correct for single line frontmatter with content', () => { + const testNote = '---\nsingle line frontmatter\n...\ncontent' + const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult + expect(extraction.frontmatterLines).toEqual(3) + }) + it('is correct for multi-line frontmatter without content', () => { + const testNote = '---\nabc\n123\ndef\n...' + const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult + expect(extraction.frontmatterLines).toEqual(5) + }) + it('is correct for multi-line frontmatter with content', () => { + const testNote = '---\nabc\n123\ndef\n...\ncontent' + const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult + expect(extraction.frontmatterLines).toEqual(5) + }) + }) + + describe('rawFrontmatterText property', () => { + it('contains single-line frontmatter text', () => { + const testNote = '---\nsingle-line\n...\ncontent' + const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult + expect(extraction.rawFrontmatterText).toEqual('single-line') + }) + it('contains multi-line frontmatter text', () => { + const testNote = '---\nmulti\nline\n...\ncontent' + const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult + expect(extraction.rawFrontmatterText).toEqual('multi\nline') + }) + }) +}) diff --git a/src/components/common/note-frontmatter/extract-frontmatter.ts b/src/components/common/note-frontmatter/extract-frontmatter.ts new file mode 100644 index 000000000..940e1e99d --- /dev/null +++ b/src/components/common/note-frontmatter/extract-frontmatter.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { FrontmatterExtractionResult } from './types' + +const FRONTMATTER_BEGIN_REGEX = /^-{3,}$/ +const FRONTMATTER_END_REGEX = /^(?:-{3,}|\.{3,})$/ + +/** + * Extracts a frontmatter block from a given multiline string. + * A valid frontmatter block requires the content to start with a line containing at least three dashes. + * The block is terminated by a line containing the same amount of dashes or dots as the first line. + * @param content The multiline string from which the frontmatter should be extracted. + * @return { frontmatterPresent } false if no frontmatter block could be found, true if a block was found. + * { rawFrontmatterText } if a block was found, this property contains the extracted text without the fencing. + * { frontmatterLines } if a block was found, this property contains the number of lines to skip from the + * given multiline string for retrieving the non-frontmatter content. + */ +export const extractFrontmatter = (content: string): FrontmatterExtractionResult => { + const lines = content.split('\n') + if (lines.length < 2 || !FRONTMATTER_BEGIN_REGEX.test(lines[0])) { + return { + frontmatterPresent: false + } + } + for (let i = 1; i < lines.length; i++) { + if (lines[i].length === lines[0].length && FRONTMATTER_END_REGEX.test(lines[i])) { + return { + frontmatterPresent: true, + rawFrontmatterText: lines.slice(1, i).join('\n'), + frontmatterLines: i + 1 + } + } + } + return { + frontmatterPresent: false + } +} diff --git a/src/components/common/note-frontmatter/note-frontmatter.test.ts b/src/components/common/note-frontmatter/note-frontmatter.test.ts new file mode 100644 index 000000000..efd0b95e8 --- /dev/null +++ b/src/components/common/note-frontmatter/note-frontmatter.test.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { NoteFrontmatter } from './note-frontmatter' + +describe('yaml frontmatter', () => { + it('should parse "title"', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml('title: test') + expect(noteFrontmatter.title).toEqual('test') + }) + + it('should parse "robots"', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml('robots: index, follow') + expect(noteFrontmatter.robots).toEqual('index, follow') + }) + + it('should parse the deprecated tags syntax', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml('tags: test123, abc') + expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) + expect(noteFrontmatter.deprecatedTagsSyntax).toEqual(true) + }) + + it('should parse the tags list syntax', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml(`tags: + - test123 + - abc + `) + expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) + expect(noteFrontmatter.deprecatedTagsSyntax).toEqual(false) + }) + + it('should parse the tag inline-list syntax', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml("tags: ['test123', 'abc']") + expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) + expect(noteFrontmatter.deprecatedTagsSyntax).toEqual(false) + }) + + it('should parse "breaks"', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml('breaks: false') + expect(noteFrontmatter.breaks).toEqual(false) + }) + + it('should parse an empty opengraph object', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml('opengraph:') + expect(noteFrontmatter.opengraph).toEqual(new Map()) + }) + + it('should parse an opengraph title', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml(`opengraph: + title: Testtitle + `) + expect(noteFrontmatter.opengraph.get('title')).toEqual('Testtitle') + }) + + it('should parse multiple opengraph values', () => { + const noteFrontmatter = NoteFrontmatter.createFromYaml(`opengraph: + title: Testtitle + 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') + }) +}) diff --git a/src/components/common/note-frontmatter/note-frontmatter.ts b/src/components/common/note-frontmatter/note-frontmatter.ts new file mode 100644 index 000000000..1c69bf01a --- /dev/null +++ b/src/components/common/note-frontmatter/note-frontmatter.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// import { RevealOptions } from 'reveal.js' +import { load } from 'js-yaml' +import { ISO6391, NoteTextDirection, NoteType, RawNoteFrontmatter } from './types' + +/** + * Class that represents the parsed frontmatter metadata of a note. + */ +export class NoteFrontmatter { + title: string + description: string + tags: string[] + deprecatedTagsSyntax: boolean + robots: string + lang: typeof ISO6391[number] + dir: NoteTextDirection + breaks: boolean + GA: string + disqus: string + type: NoteType + opengraph: Map + + /** + * Creates a new frontmatter metadata instance based on the given raw metadata properties. + * @param rawData A {@link RawNoteFrontmatter} object containing the properties of the parsed yaml frontmatter. + */ + constructor(rawData: RawNoteFrontmatter) { + this.title = rawData.title ?? '' + this.description = rawData.description ?? '' + this.robots = rawData.robots ?? '' + this.breaks = rawData.breaks ?? true + this.GA = rawData.GA ?? '' + this.disqus = rawData.disqus ?? '' + this.lang = (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? 'en' + this.type = + (rawData.type ? Object.values(NoteType).find((type) => type === rawData.type) : undefined) ?? NoteType.DOCUMENT + this.dir = + (rawData.dir ? Object.values(NoteTextDirection).find((dir) => dir === rawData.dir) : undefined) ?? + NoteTextDirection.LTR + if (typeof rawData?.tags === 'string') { + this.tags = rawData?.tags?.split(',').map((entry) => entry.trim()) ?? [] + this.deprecatedTagsSyntax = true + } else if (typeof rawData?.tags === 'object') { + this.tags = rawData?.tags?.filter((tag) => tag !== null) ?? [] + this.deprecatedTagsSyntax = false + } else { + this.tags = [] + this.deprecatedTagsSyntax = false + } + this.opengraph = rawData?.opengraph + ? new Map(Object.entries(rawData.opengraph)) + : new Map() + } + + /** + * Creates a new frontmatter metadata instance based on a raw yaml string. + * @param rawYaml The frontmatter content in yaml format. + * @throws Error when the content string is invalid yaml. + * @return Frontmatter metadata instance containing the parsed properties from the yaml content. + */ + static createFromYaml(rawYaml: string): NoteFrontmatter { + const rawNoteFrontmatter = load(rawYaml) as RawNoteFrontmatter + return new NoteFrontmatter(rawNoteFrontmatter) + } +} diff --git a/src/components/editor-page/note-frontmatter/note-frontmatter.ts b/src/components/common/note-frontmatter/types.ts similarity index 59% rename from src/components/editor-page/note-frontmatter/note-frontmatter.ts rename to src/components/common/note-frontmatter/types.ts index 8185c9e73..4b1ebd699 100644 --- a/src/components/editor-page/note-frontmatter/note-frontmatter.ts +++ b/src/components/common/note-frontmatter/types.ts @@ -4,7 +4,23 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// import { RevealOptions } from 'reveal.js' +export type FrontmatterExtractionResult = PresentFrontmatterExtractionResult | NonPresentFrontmatterExtractionResult + +export interface RendererFrontmatterInfo { + offsetLines: number + frontmatterInvalid: boolean + deprecatedSyntax: boolean +} + +export interface PresentFrontmatterExtractionResult { + frontmatterPresent: true + rawFrontmatterText: string + frontmatterLines: number +} + +interface NonPresentFrontmatterExtractionResult { + frontmatterPresent: false +} export interface RawNoteFrontmatter { title: string | undefined @@ -235,52 +251,3 @@ export enum NoteTextDirection { LTR = 'ltr', RTL = 'rtl' } - -export class NoteFrontmatter { - title: string - description: string - tags: string[] - deprecatedTagsSyntax: boolean - robots: string - lang: typeof ISO6391[number] - dir: NoteTextDirection - breaks: boolean - GA: string - disqus: string - type: NoteType - // slideOptions: RevealOptions - opengraph: Map - - constructor(rawData: RawNoteFrontmatter) { - this.title = rawData.title ?? '' - this.description = rawData.description ?? '' - this.robots = rawData.robots ?? '' - this.breaks = rawData.breaks ?? true - this.GA = rawData.GA ?? '' - this.disqus = rawData.disqus ?? '' - this.lang = (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? 'en' - this.type = - (rawData.type ? Object.values(NoteType).find((type) => type === rawData.type) : undefined) ?? NoteType.DOCUMENT - this.dir = - (rawData.dir ? Object.values(NoteTextDirection).find((dir) => dir === rawData.dir) : undefined) ?? - NoteTextDirection.LTR - - /* this.slideOptions = (rawData?.slideOptions as RevealOptions) ?? { - transition: 'none', - theme: 'white' - } */ - if (typeof rawData?.tags === 'string') { - this.tags = rawData?.tags?.split(',').map((entry) => entry.trim()) ?? [] - this.deprecatedTagsSyntax = true - } else if (typeof rawData?.tags === 'object') { - this.tags = rawData?.tags?.filter((tag) => tag !== null) ?? [] - this.deprecatedTagsSyntax = false - } else { - this.tags = [] - this.deprecatedTagsSyntax = false - } - this.opengraph = rawData?.opengraph - ? new Map(Object.entries(rawData.opengraph)) - : new Map() - } -} diff --git a/src/components/document-read-only-page/document-read-only-page.tsx b/src/components/document-read-only-page/document-read-only-page.tsx index d919c4abc..befee0a99 100644 --- a/src/components/document-read-only-page/document-read-only-page.tsx +++ b/src/components/document-read-only-page/document-read-only-page.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router' import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode' import { useDocumentTitleWithNoteTitle } from '../../hooks/common/use-document-title-with-note-title' import { useNoteMarkdownContent } from '../../hooks/common/use-note-markdown-content' -import { setNoteFrontmatter, updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' +import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' import { MotdBanner } from '../common/motd-banner/motd-banner' import { ShowIf } from '../common/show-if/show-if' import { AppBar, AppBarMode } from '../editor-page/app-bar/app-bar' @@ -32,7 +32,6 @@ export const DocumentReadOnlyPage: React.FC = () => { useDocumentTitleWithNoteTitle() const onFirstHeadingChange = useCallback(updateNoteTitleByFirstHeading, []) - const onFrontmatterChange = useCallback(setNoteFrontmatter, []) const [error, loading] = useLoadNoteFromServer() const markdownContent = useNoteMarkdownContent() const noteDetails = useApplicationState((state) => state.noteDetails) @@ -60,7 +59,6 @@ export const DocumentReadOnlyPage: React.FC = () => { frameClasses={'flex-fill h-100 w-100'} markdownContent={markdownContent} onFirstHeadingChange={onFirstHeadingChange} - onFrontmatterChange={onFrontmatterChange} rendererType={RendererType.DOCUMENT} /> diff --git a/src/components/editor-page/app-bar/app-bar.tsx b/src/components/editor-page/app-bar/app-bar.tsx index 1375da7b1..01099e76d 100644 --- a/src/components/editor-page/app-bar/app-bar.tsx +++ b/src/components/editor-page/app-bar/app-bar.tsx @@ -14,7 +14,7 @@ import { EditorViewMode } from './editor-view-mode' import { HelpButton } from './help-button/help-button' import { NavbarBranding } from './navbar-branding' import { SyncScrollButtons } from './sync-scroll-buttons/sync-scroll-buttons' -import { NoteType } from '../note-frontmatter/note-frontmatter' +import { NoteType } from '../../common/note-frontmatter/types' import { SlideModeButton } from './slide-mode-button' import { ReadOnlyModeButton } from './read-only-mode-button' import { NewNoteButton } from './new-note-button' diff --git a/src/components/editor-page/document-bar/share/share-modal.tsx b/src/components/editor-page/document-bar/share/share-modal.tsx index cf7b3fb25..edd87c4fa 100644 --- a/src/components/editor-page/document-bar/share/share-modal.tsx +++ b/src/components/editor-page/document-bar/share/share-modal.tsx @@ -13,7 +13,7 @@ import { CopyableField } from '../../../common/copyable/copyable-field/copyable- import { CommonModal } from '../../../common/modals/common-modal' import { ShowIf } from '../../../common/show-if/show-if' import { EditorPagePathParams } from '../../editor-page' -import { NoteType } from '../../note-frontmatter/note-frontmatter' +import { NoteType } from '../../../common/note-frontmatter/types' import { useApplicationState } from '../../../../hooks/common/use-application-state' export interface ShareModalProps { diff --git a/src/components/editor-page/editor-page.tsx b/src/components/editor-page/editor-page.tsx index 4b9a7382a..ca9a7e987 100644 --- a/src/components/editor-page/editor-page.tsx +++ b/src/components/editor-page/editor-page.tsx @@ -11,8 +11,7 @@ import { useDocumentTitleWithNoteTitle } from '../../hooks/common/use-document-t import { useNoteMarkdownContent } from '../../hooks/common/use-note-markdown-content' import { setCheckboxInMarkdownContent, - setNoteFrontmatter, - setNoteMarkdownContent, + setNoteContent, updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' import { MotdBanner } from '../common/motd-banner/motd-banner' @@ -50,6 +49,7 @@ export const EditorPage: React.FC = () => { const markdownContent = useNoteMarkdownContent() const scrollSource = useRef(ScrollSource.EDITOR) + const documentContent = useApplicationState((state) => state.noteDetails.documentContent) const editorMode: EditorMode = useApplicationState((state) => state.editorConfig.editorMode) const editorSyncScroll: boolean = useApplicationState((state) => state.editorConfig.syncScroll) @@ -98,14 +98,14 @@ export const EditorPage: React.FC = () => { const leftPane = useMemo( () => ( ), - [markdownContent, onEditorScroll, scrollState.editorScrollState, setEditorToScrollSource] + [documentContent, onEditorScroll, scrollState.editorScrollState, setEditorToScrollSource] ) const rightPane = useMemo( @@ -116,7 +116,6 @@ export const EditorPage: React.FC = () => { onMakeScrollSource={setRendererToScrollSource} onFirstHeadingChange={updateNoteTitleByFirstHeading} onTaskCheckedChange={setCheckboxInMarkdownContent} - onFrontmatterChange={setNoteFrontmatter} onScroll={onMarkdownRendererScroll} scrollState={scrollState.rendererScrollState} rendererType={RendererType.DOCUMENT} diff --git a/src/components/editor-page/note-frontmatter/note-frontmatter.test.ts b/src/components/editor-page/note-frontmatter/note-frontmatter.test.ts deleted file mode 100644 index 3661e0a44..000000000 --- a/src/components/editor-page/note-frontmatter/note-frontmatter.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import yaml from 'js-yaml' -import MarkdownIt from 'markdown-it' -import frontmatter from 'markdown-it-front-matter' -import { NoteFrontmatter, RawNoteFrontmatter } from './note-frontmatter' - -describe('yaml frontmatter', () => { - const testFrontmatter = (input: string): NoteFrontmatter => { - let processedFrontmatter: NoteFrontmatter | undefined = undefined - const md = new MarkdownIt('default', { - html: true, - breaks: true, - langPrefix: '', - typographer: true - }) - md.use(frontmatter, (rawMeta: string) => { - const parsedFrontmatter = yaml.load(rawMeta) as RawNoteFrontmatter | undefined - expect(parsedFrontmatter).not.toBe(undefined) - if (parsedFrontmatter === undefined) { - fail('Parsed frontmatter is undefined') - } - processedFrontmatter = new NoteFrontmatter(parsedFrontmatter) - }) - - md.render(input) - - if (processedFrontmatter === undefined) { - fail('NoteFrontmatter is undefined') - } - - return processedFrontmatter - } - - it('should parse "title"', () => { - const noteFrontmatter = testFrontmatter(`--- - title: test - ___ - `) - - expect(noteFrontmatter.title).toEqual('test') - }) - - it('should parse "robots"', () => { - const noteFrontmatter = testFrontmatter(`--- - robots: index, follow - ___ - `) - - expect(noteFrontmatter.robots).toEqual('index, follow') - }) - - it('should parse the deprecated tags syntax', () => { - const noteFrontmatter = testFrontmatter(`--- - tags: test123, abc - ___ - `) - - expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) - expect(noteFrontmatter.deprecatedTagsSyntax).toEqual(true) - }) - - it('should parse the tags list syntax', () => { - const noteFrontmatter = testFrontmatter(`--- - tags: - - test123 - - abc - ___ - `) - - expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) - expect(noteFrontmatter.deprecatedTagsSyntax).toEqual(false) - }) - - it('should parse the tag inline-list syntax', () => { - const noteFrontmatter = testFrontmatter(`--- - tags: ['test123', 'abc'] - ___ - `) - - expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) - expect(noteFrontmatter.deprecatedTagsSyntax).toEqual(false) - }) - - it('should parse "breaks"', () => { - const noteFrontmatter = testFrontmatter(`--- - breaks: false - ___ - `) - - expect(noteFrontmatter.breaks).toEqual(false) - }) - - /* - it('slideOptions nothing', () => { - testFrontmatter(`--- - slideOptions: - ___ - `, - { - slideOptions: null - }, - { - slideOptions: { - theme: 'white', - transition: 'none' - } - }) - }) - - it('slideOptions.theme only', () => { - testFrontmatter(`--- - slideOptions: - theme: sky - ___ - `, - { - slideOptions: { - theme: 'sky', - transition: undefined - } - }, - { - slideOptions: { - theme: 'sky', - transition: 'none' - } - }) - }) - - it('slideOptions full', () => { - testFrontmatter(`--- - slideOptions: - transition: zoom - theme: sky - ___ - `, - { - slideOptions: { - theme: 'sky', - transition: 'zoom' - } - }, - { - slideOptions: { - theme: 'sky', - transition: 'zoom' - } - }) - }) - */ - - it('should parse an empty opengraph object', () => { - const noteFrontmatter = testFrontmatter(`--- - opengraph: - ___ - `) - - expect(noteFrontmatter.opengraph).toEqual(new Map()) - }) - - it('should parse an opengraph title', () => { - const noteFrontmatter = testFrontmatter(`--- - opengraph: - title: Testtitle - ___ - `) - - expect(noteFrontmatter.opengraph.get('title')).toEqual('Testtitle') - }) - - it('should opengraph values', () => { - const noteFrontmatter = testFrontmatter(`--- - opengraph: - title: Testtitle - 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') - }) -}) diff --git a/src/components/editor-page/renderer-pane/render-iframe.tsx b/src/components/editor-page/renderer-pane/render-iframe.tsx index 1190d1594..4a30402fd 100644 --- a/src/components/editor-page/renderer-pane/render-iframe.tsx +++ b/src/components/editor-page/renderer-pane/render-iframe.tsx @@ -25,7 +25,6 @@ export interface RenderIframeProps extends RendererProps { export const RenderIframe: React.FC = ({ markdownContent, onTaskCheckedChange, - onFrontmatterChange, scrollState, onFirstHeadingChange, onScroll, @@ -39,6 +38,7 @@ export const RenderIframe: React.FC = ({ const [lightboxDetails, setLightboxDetails] = useState(undefined) const frameReference = useRef(null) + const frontmatterInfo = useApplicationState((state) => state.noteDetails.frontmatterRendererInfo) const rendererOrigin = useApplicationState((state) => state.config.iframeCommunication.rendererOrigin) const renderPageUrl = `${rendererOrigin}render` const resetRendererReady = useCallback(() => setRendererStatus(false), []) @@ -67,11 +67,6 @@ export const RenderIframe: React.FC = ({ return () => iframeCommunicator.onFirstHeadingChange(undefined) }, [iframeCommunicator, onFirstHeadingChange]) - useEffect(() => { - iframeCommunicator.onFrontmatterChange(onFrontmatterChange) - return () => iframeCommunicator.onFrontmatterChange(undefined) - }, [iframeCommunicator, onFrontmatterChange]) - useEffect(() => { iframeCommunicator.onSetScrollState(onScroll) return () => iframeCommunicator.onSetScrollState(undefined) @@ -128,6 +123,12 @@ export const RenderIframe: React.FC = ({ } }, [iframeCommunicator, markdownContent, rendererReady]) + useEffect(() => { + if (rendererReady && frontmatterInfo !== undefined) { + iframeCommunicator.sendSetFrontmatterInfo(frontmatterInfo) + } + }, [iframeCommunicator, rendererReady, frontmatterInfo]) + return ( diff --git a/src/components/editor-page/renderer-pane/yaml-array-deprecation-alert.tsx b/src/components/editor-page/renderer-pane/yaml-array-deprecation-alert.tsx index a0bc08697..9aefd1bf4 100644 --- a/src/components/editor-page/renderer-pane/yaml-array-deprecation-alert.tsx +++ b/src/components/editor-page/renderer-pane/yaml-array-deprecation-alert.tsx @@ -10,14 +10,13 @@ import { Trans, useTranslation } from 'react-i18next' import links from '../../../links.json' import { TranslatedExternalLink } from '../../common/links/translated-external-link' import { ShowIf } from '../../common/show-if/show-if' -import { useApplicationState } from '../../../hooks/common/use-application-state' +import { CommonModalProps } from '../../common/modals/common-modal' -export const YamlArrayDeprecationAlert: React.FC = () => { +export const YamlArrayDeprecationAlert: React.FC> = ({ show }) => { useTranslation() - const yamlDeprecatedTags = useApplicationState((state) => state.noteDetails.frontmatter.deprecatedTagsSyntax) return ( - + diff --git a/src/components/editor-page/sidebar/export-markdown-sidebar-entry.tsx b/src/components/editor-page/sidebar/export-markdown-sidebar-entry.tsx index c671584f2..8228a6244 100644 --- a/src/components/editor-page/sidebar/export-markdown-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/export-markdown-sidebar-entry.tsx @@ -8,17 +8,17 @@ import React, { useCallback } from 'react' import sanitize from 'sanitize-filename' import { store } from '../../../redux' import { Trans, useTranslation } from 'react-i18next' -import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content' import { download } from '../../common/download/download' import { SidebarButton } from './sidebar-button' +import { useApplicationState } from '../../../hooks/common/use-application-state' export const ExportMarkdownSidebarEntry: React.FC = () => { const { t } = useTranslation() - const markdownContent = useNoteMarkdownContent() + const documentContent = useApplicationState((state) => state.noteDetails.documentContent) const onClick = useCallback(() => { const sanitized = sanitize(store.getState().noteDetails.noteTitle) - download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown') - }, [markdownContent, t]) + download(documentContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown') + }, [documentContent, t]) return ( diff --git a/src/components/editor-page/sidebar/import-markdown-sidebar-entry.tsx b/src/components/editor-page/sidebar/import-markdown-sidebar-entry.tsx index 56dfb2ada..f7987fe15 100644 --- a/src/components/editor-page/sidebar/import-markdown-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/import-markdown-sidebar-entry.tsx @@ -7,7 +7,7 @@ import React, { Fragment, useCallback, useRef } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content' -import { setNoteMarkdownContent } from '../../../redux/note-details/methods' +import { setNoteContent } from '../../../redux/note-details/methods' import { SidebarButton } from './sidebar-button' import { UploadInput } from './upload-input' @@ -21,7 +21,7 @@ export const ImportMarkdownSidebarEntry: React.FC = () => { const fileReader = new FileReader() fileReader.addEventListener('load', () => { const newContent = fileReader.result as string - setNoteMarkdownContent(markdownContent.length === 0 ? newContent : `${markdownContent}\n${newContent}`) + setNoteContent(markdownContent.length === 0 ? newContent : `${markdownContent}\n${newContent}`) }) fileReader.addEventListener('loadend', () => { resolve() diff --git a/src/components/markdown-renderer/basic-markdown-renderer.tsx b/src/components/markdown-renderer/basic-markdown-renderer.tsx index 2beccb491..d65102e8c 100644 --- a/src/components/markdown-renderer/basic-markdown-renderer.tsx +++ b/src/components/markdown-renderer/basic-markdown-renderer.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Ref, useCallback, useMemo, useRef, useState } from 'react' +import React, { Ref, useCallback, useMemo, useRef } from 'react' import { DocumentLengthLimitReachedAlert } from './document-length-limit-reached-alert' import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom' import './markdown-renderer.scss' @@ -12,7 +12,6 @@ import { ComponentReplacer } from './replace-components/ComponentReplacer' import { AdditionalMarkdownRendererProps, LineMarkerPosition } from './types' import { useComponentReplacers } from './hooks/use-component-replacers' import { useTranslation } from 'react-i18next' -import { NoteFrontmatter, RawNoteFrontmatter } from '../editor-page/note-frontmatter/note-frontmatter' import { LineMarkers } from './replace-components/linemarker/line-number-marker' import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions' import { useExtractFirstHeadline } from './hooks/use-extract-first-headline' @@ -20,7 +19,6 @@ import { TocAst } from 'markdown-it-toc-done-right' import { useOnRefChange } from './hooks/use-on-ref-change' import { BasicMarkdownItConfigurator } from './markdown-it-configurator/BasicMarkdownItConfigurator' import { ImageClickHandler } from './replace-components/image/image-replacer' -import { InvalidYamlAlert } from './invalid-yaml-alert' import { useTrimmedContent } from './hooks/use-trimmed-content' export interface BasicMarkdownRendererProps { @@ -29,79 +27,57 @@ export interface BasicMarkdownRendererProps { onAfterRendering?: () => void onFirstHeadingChange?: (firstHeading: string | undefined) => void onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void - onFrontmatterChange?: (frontmatter: NoteFrontmatter | undefined) => void onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void onTocChange?: (ast?: TocAst) => void baseUrl?: string onImageClick?: ImageClickHandler outerContainerRef?: Ref useAlternativeBreaks?: boolean + frontmatterLineOffset?: number } export const BasicMarkdownRenderer: React.FC = ({ className, content, additionalReplacers, - onBeforeRendering, - onAfterRendering, onFirstHeadingChange, onLineMarkerPositionChanged, - onFrontmatterChange, onTaskCheckedChange, onTocChange, baseUrl, onImageClick, outerContainerRef, - useAlternativeBreaks + useAlternativeBreaks, + frontmatterLineOffset }) => { - const rawMetaRef = useRef() const markdownBodyRef = useRef(null) const currentLineMarkers = useRef() const hasNewYamlError = useRef(false) const tocAst = useRef() - const [showYamlError, setShowYamlError] = useState(false) const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content) const markdownIt = useMemo( () => new BasicMarkdownItConfigurator({ - useFrontmatter: !!onFrontmatterChange, onParseError: (errorState) => (hasNewYamlError.current = errorState), - onRawMetaChange: (rawMeta) => (rawMetaRef.current = rawMeta), onToc: (toc) => (tocAst.current = toc), onLineMarkers: onLineMarkerPositionChanged === undefined ? undefined : (lineMarkers) => (currentLineMarkers.current = lineMarkers), - useAlternativeBreaks + useAlternativeBreaks, + offsetLines: frontmatterLineOffset }).buildConfiguredMarkdownIt(), - [onFrontmatterChange, onLineMarkerPositionChanged, useAlternativeBreaks] + [onLineMarkerPositionChanged, useAlternativeBreaks, frontmatterLineOffset] ) - const clearFrontmatter = useCallback(() => { - hasNewYamlError.current = false - rawMetaRef.current = undefined - onBeforeRendering?.() - }, [onBeforeRendering]) - - const checkYamlErrorState = useCallback(() => { - setShowYamlError(hasNewYamlError.current) - onAfterRendering?.() - }, [onAfterRendering]) - - const baseReplacers = useComponentReplacers(onTaskCheckedChange, onImageClick, baseUrl) + const baseReplacers = useComponentReplacers(onTaskCheckedChange, onImageClick, baseUrl, frontmatterLineOffset) const replacers = useCallback( () => baseReplacers().concat(additionalReplacers ? additionalReplacers() : []), [additionalReplacers, baseReplacers] ) - const markdownReactDom = useConvertMarkdownToReactDom( - trimmedContent, - markdownIt, - replacers, - clearFrontmatter, - checkYamlErrorState - ) + const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, markdownIt, replacers) useTranslation() useCalculateLineMarkerPosition( @@ -112,17 +88,9 @@ export const BasicMarkdownRenderer: React.FC { - if (!newValue) { - onFrontmatterChange?.(undefined) - } else { - onFrontmatterChange?.(new NoteFrontmatter(newValue)) - } - }) return (
-
ComponentReplacer[]) => useCallback( () => [ @@ -59,8 +61,8 @@ export const useComponentReplacers = ( new HighlightedCodeReplacer(), new ColoredBlockquoteReplacer(), new KatexReplacer(), - new TaskListReplacer(onTaskCheckedChange), + new TaskListReplacer(onTaskCheckedChange, frontmatterLinesToSkip), new LinkReplacer(baseUrl) ], - [onImageClick, onTaskCheckedChange, baseUrl] + [onImageClick, onTaskCheckedChange, baseUrl, frontmatterLinesToSkip] ) diff --git a/src/components/markdown-renderer/markdown-it-configurator/BasicMarkdownItConfigurator.tsx b/src/components/markdown-renderer/markdown-it-configurator/BasicMarkdownItConfigurator.tsx index d56e48029..33c7fa8fe 100644 --- a/src/components/markdown-renderer/markdown-it-configurator/BasicMarkdownItConfigurator.tsx +++ b/src/components/markdown-renderer/markdown-it-configurator/BasicMarkdownItConfigurator.tsx @@ -19,7 +19,6 @@ import { MarkdownItParserDebugger } from '../markdown-it-plugins/parser-debugger import { spoilerContainer } from '../markdown-it-plugins/spoiler-container' import { tasksLists } from '../markdown-it-plugins/tasks-lists' import { twitterEmojis } from '../markdown-it-plugins/twitter-emojis' -import { RawNoteFrontmatter } from '../../editor-page/note-frontmatter/note-frontmatter' import { TocAst } from 'markdown-it-toc-done-right' import { LineMarkers, lineNumberMarker } from '../replace-components/linemarker/line-number-marker' import { plantumlWithError } from '../markdown-it-plugins/plantuml' @@ -36,15 +35,13 @@ import { highlightedCode } from '../markdown-it-plugins/highlighted-code' import { quoteExtraColor } from '../markdown-it-plugins/quote-extra-color' import { quoteExtra } from '../markdown-it-plugins/quote-extra' import { documentTableOfContents } from '../markdown-it-plugins/document-table-of-contents' -import { frontmatterExtract } from '../markdown-it-plugins/frontmatter' export interface ConfiguratorDetails { - useFrontmatter: boolean onParseError: (error: boolean) => void - onRawMetaChange: (rawMeta: RawNoteFrontmatter) => void onToc: (toc: TocAst) => void onLineMarkers?: (lineMarkers: LineMarkers[]) => void useAlternativeBreaks?: boolean + offsetLines?: number } export class BasicMarkdownItConfigurator { @@ -105,17 +102,8 @@ export class BasicMarkdownItConfigurator { spoilerContainer ) - if (this.options.useFrontmatter) { - this.configurations.push( - frontmatterExtract({ - onParseError: this.options.onParseError, - onRawMetaChange: this.options.onRawMetaChange - }) - ) - } - if (this.options.onLineMarkers) { - this.configurations.push(lineNumberMarker(this.options.onLineMarkers)) + this.configurations.push(lineNumberMarker(this.options.onLineMarkers, this.options.offsetLines ?? 0)) } this.postConfigurations.push(linkifyExtra, MarkdownItParserDebugger) diff --git a/src/components/markdown-renderer/markdown-it-plugins/frontmatter.ts b/src/components/markdown-renderer/markdown-it-plugins/frontmatter.ts deleted file mode 100644 index 4376c5523..000000000 --- a/src/components/markdown-renderer/markdown-it-plugins/frontmatter.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import yaml from 'js-yaml' -import MarkdownIt from 'markdown-it' -import frontmatter from 'markdown-it-front-matter' -import { RawNoteFrontmatter } from '../../editor-page/note-frontmatter/note-frontmatter' - -interface FrontmatterPluginOptions { - onParseError: (error: boolean) => void - onRawMetaChange: (rawMeta: RawNoteFrontmatter) => void -} - -export const frontmatterExtract: (options: FrontmatterPluginOptions) => MarkdownIt.PluginSimple = - (options) => (markdownIt) => { - frontmatter(markdownIt, (rawMeta: string) => { - try { - const meta: RawNoteFrontmatter = yaml.load(rawMeta) as RawNoteFrontmatter - options.onParseError(false) - options.onRawMetaChange(meta) - } catch (e) { - console.error(e) - options.onParseError(true) - options.onRawMetaChange({} as RawNoteFrontmatter) - } - }) - } diff --git a/src/components/markdown-renderer/replace-components/linemarker/line-number-marker.ts b/src/components/markdown-renderer/replace-components/linemarker/line-number-marker.ts index 1ec70fc35..d48416d35 100644 --- a/src/components/markdown-renderer/replace-components/linemarker/line-number-marker.ts +++ b/src/components/markdown-renderer/replace-components/linemarker/line-number-marker.ts @@ -18,12 +18,13 @@ export type LineNumberMarkerOptions = (lineMarkers: LineMarkers[]) => void * This plugin adds markers to the dom, that are used to map line numbers to dom elements. * It also provides a list of line numbers for the top level dom elements. */ -export const lineNumberMarker: (options: LineNumberMarkerOptions) => MarkdownIt.PluginSimple = - (options) => (md: MarkdownIt) => { +export const lineNumberMarker: (options: LineNumberMarkerOptions, offsetLines: number) => MarkdownIt.PluginSimple = + (options, offsetLines = 0) => + (md: MarkdownIt) => { // add app_linemarker token before each opening or self-closing level-0 tag md.core.ruler.push('line_number_marker', (state) => { const lineMarkers: LineMarkers[] = [] - tagTokens(state.tokens, lineMarkers) + tagTokens(state.tokens, lineMarkers, offsetLines) if (options) { options(lineMarkers) } @@ -56,7 +57,7 @@ export const lineNumberMarker: (options: LineNumberMarkerOptions) => MarkdownIt. tokens.splice(tokenPosition, 0, startToken) } - const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[]) => { + const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[], offsetLines: number) => { for (let tokenPosition = 0; tokenPosition < tokens.length; tokenPosition++) { const token = tokens[tokenPosition] if (token.hidden) { @@ -71,14 +72,14 @@ export const lineNumberMarker: (options: LineNumberMarkerOptions) => MarkdownIt. const endLineNumber = token.map[1] + 1 if (token.level === 0) { - lineMarkers.push({ startLine: startLineNumber, endLine: endLineNumber }) + lineMarkers.push({ startLine: startLineNumber + offsetLines, endLine: endLineNumber + offsetLines }) } insertNewLineMarker(startLineNumber, endLineNumber, tokenPosition, token.level, tokens) tokenPosition += 1 if (token.children) { - tagTokens(token.children, lineMarkers) + tagTokens(token.children, lineMarkers, offsetLines) } } } diff --git a/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx b/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx index a0a8dcb3a..5a4996eac 100644 --- a/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx @@ -15,16 +15,18 @@ export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean */ export class TaskListReplacer extends ComponentReplacer { onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void + private readonly frontmatterLinesOffset - constructor(onTaskCheckedChange?: TaskCheckedChangeHandler) { + constructor(onTaskCheckedChange?: TaskCheckedChangeHandler, frontmatterLinesOffset?: number) { super() this.onTaskCheckedChange = onTaskCheckedChange + this.frontmatterLinesOffset = frontmatterLinesOffset ?? 0 } handleCheckboxChange = (event: React.ChangeEvent): void => { const lineNum = Number(event.currentTarget.dataset.line) if (this.onTaskCheckedChange) { - this.onTaskCheckedChange(lineNum, event.currentTarget.checked) + this.onTaskCheckedChange(lineNum + this.frontmatterLinesOffset, event.currentTarget.checked) } } diff --git a/src/components/render-page/iframe-editor-to-renderer-communicator.ts b/src/components/render-page/iframe-editor-to-renderer-communicator.ts index efd1c1a01..e376e2864 100644 --- a/src/components/render-page/iframe-editor-to-renderer-communicator.ts +++ b/src/components/render-page/iframe-editor-to-renderer-communicator.ts @@ -3,8 +3,6 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - -import { NoteFrontmatter } from '../editor-page/note-frontmatter/note-frontmatter' import { ScrollState } from '../editor-page/synced-scroll/scroll-props' import { IframeCommunicator } from './iframe-communicator' import { @@ -14,6 +12,7 @@ import { RendererToEditorIframeMessage, RenderIframeMessageType } from './rendering-message' +import { RendererFrontmatterInfo } from '../common/note-frontmatter/types' export class IframeEditorToRendererCommunicator extends IframeCommunicator< EditorToRendererIframeMessage, @@ -22,7 +21,6 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< private onSetScrollSourceToRendererHandler?: () => void private onTaskCheckboxChangeHandler?: (lineInMarkdown: number, checked: boolean) => void private onFirstHeadingChangeHandler?: (heading?: string) => void - private onFrontmatterChangeHandler?: (frontmatter?: NoteFrontmatter) => void private onSetScrollStateHandler?: (scrollState: ScrollState) => void private onRendererReadyHandler?: () => void private onImageClickedHandler?: (details: ImageDetails) => void @@ -33,10 +31,6 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< this.onHeightChangeHandler = handler } - public onFrontmatterChange(handler?: (frontmatter?: NoteFrontmatter) => void): void { - this.onFrontmatterChangeHandler = handler - } - public onImageClicked(handler?: (details: ImageDetails) => void): void { this.onImageClickedHandler = handler } @@ -102,6 +96,13 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< }) } + public sendSetFrontmatterInfo(frontmatterInfo: RendererFrontmatterInfo): void { + this.sendMessageToOtherSide({ + type: RenderIframeMessageType.SET_FRONTMATTER_INFO, + frontmatterInfo: frontmatterInfo + }) + } + protected handleEvent(event: MessageEvent): boolean | undefined { const renderMessage = event.data switch (renderMessage.type) { @@ -121,9 +122,6 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< case RenderIframeMessageType.ON_TASK_CHECKBOX_CHANGE: this.onTaskCheckboxChangeHandler?.(renderMessage.lineInMarkdown, renderMessage.checked) return false - case RenderIframeMessageType.ON_SET_FRONTMATTER: - this.onFrontmatterChangeHandler?.(renderMessage.frontmatter) - return false case RenderIframeMessageType.IMAGE_CLICKED: this.onImageClickedHandler?.(renderMessage.details) return false diff --git a/src/components/render-page/iframe-markdown-renderer.tsx b/src/components/render-page/iframe-markdown-renderer.tsx index 14692d2da..b68ef026a 100644 --- a/src/components/render-page/iframe-markdown-renderer.tsx +++ b/src/components/render-page/iframe-markdown-renderer.tsx @@ -8,18 +8,22 @@ import React, { useCallback, useEffect, useState } from 'react' import { ScrollState } from '../editor-page/synced-scroll/scroll-props' import { BaseConfiguration, RendererType } from './rendering-message' import { setDarkMode } from '../../redux/dark-mode/methods' -import { NoteFrontmatter } from '../editor-page/note-frontmatter/note-frontmatter' -import { setNoteFrontmatter } from '../../redux/note-details/methods' import { ImageClickHandler } from '../markdown-renderer/replace-components/image/image-replacer' import { useImageClickHandler } from './hooks/use-image-click-handler' import { MarkdownDocument } from './markdown-document' import { useIFrameRendererToEditorCommunicator } from '../editor-page/render-context/iframe-renderer-to-editor-communicator-context-provider' import { countWords } from './word-counter' +import { RendererFrontmatterInfo } from '../common/note-frontmatter/types' export const IframeMarkdownRenderer: React.FC = () => { const [markdownContent, setMarkdownContent] = useState('') const [scrollState, setScrollState] = useState({ firstLineInView: 1, scrolledPercentage: 0 }) const [baseConfiguration, setBaseConfiguration] = useState(undefined) + const [frontmatterInfo, setFrontmatterInfo] = useState({ + offsetLines: 0, + frontmatterInvalid: false, + deprecatedSyntax: false + }) const iframeCommunicator = useIFrameRendererToEditorCommunicator() @@ -37,6 +41,7 @@ export const IframeMarkdownRenderer: React.FC = () => { useEffect(() => iframeCommunicator.onSetMarkdownContent(setMarkdownContent), [iframeCommunicator]) useEffect(() => iframeCommunicator.onSetDarkMode(setDarkMode), [iframeCommunicator]) useEffect(() => iframeCommunicator.onSetScrollState(setScrollState), [iframeCommunicator, scrollState]) + useEffect(() => iframeCommunicator.onSetFrontmatterInfo(setFrontmatterInfo), [iframeCommunicator, setFrontmatterInfo]) useEffect( () => iframeCommunicator.onGetWordCount(countWordsInRenderedDocument), [iframeCommunicator, countWordsInRenderedDocument] @@ -60,14 +65,6 @@ export const IframeMarkdownRenderer: React.FC = () => { iframeCommunicator.sendSetScrollSourceToRenderer() }, [iframeCommunicator]) - const onFrontmatterChange = useCallback( - (frontmatter?: NoteFrontmatter) => { - setNoteFrontmatter(frontmatter) - iframeCommunicator.sendSetFrontmatter(frontmatter) - }, - [iframeCommunicator] - ) - const onScroll = useCallback( (scrollState: ScrollState) => { iframeCommunicator.sendSetScrollState(scrollState) @@ -97,11 +94,11 @@ export const IframeMarkdownRenderer: React.FC = () => { onTaskCheckedChange={onTaskCheckedChange} onFirstHeadingChange={onFirstHeadingChange} onMakeScrollSource={onMakeScrollSource} - onFrontmatterChange={onFrontmatterChange} scrollState={scrollState} onScroll={onScroll} baseUrl={baseConfiguration.baseUrl} onImageClick={onImageClick} + frontmatterInfo={frontmatterInfo} /> ) case RendererType.INTRO: diff --git a/src/components/render-page/iframe-renderer-to-editor-communicator.ts b/src/components/render-page/iframe-renderer-to-editor-communicator.ts index 6a9c83e85..c874fd677 100644 --- a/src/components/render-page/iframe-renderer-to-editor-communicator.ts +++ b/src/components/render-page/iframe-renderer-to-editor-communicator.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { NoteFrontmatter } from '../editor-page/note-frontmatter/note-frontmatter' import { ScrollState } from '../editor-page/synced-scroll/scroll-props' import { IframeCommunicator } from './iframe-communicator' import { @@ -14,6 +13,7 @@ import { RendererToEditorIframeMessage, RenderIframeMessageType } from './rendering-message' +import { RendererFrontmatterInfo } from '../common/note-frontmatter/types' export class IframeRendererToEditorCommunicator extends IframeCommunicator< RendererToEditorIframeMessage, @@ -24,6 +24,7 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< private onSetScrollStateHandler?: (scrollState: ScrollState) => void private onSetBaseConfigurationHandler?: (baseConfiguration: BaseConfiguration) => void private onGetWordCountHandler?: () => void + private onSetFrontmatterInfoHandler?: (frontmatterInfo: RendererFrontmatterInfo) => void public onSetBaseConfiguration(handler?: (baseConfiguration: BaseConfiguration) => void): void { this.onSetBaseConfigurationHandler = handler @@ -45,6 +46,10 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< this.onGetWordCountHandler = handler } + public onSetFrontmatterInfo(handler?: (frontmatterInfo: RendererFrontmatterInfo) => void): void { + this.onSetFrontmatterInfoHandler = handler + } + public sendRendererReady(): void { this.enableCommunication() this.sendMessageToOtherSide({ @@ -73,13 +78,6 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< }) } - public sendSetFrontmatter(frontmatter: NoteFrontmatter | undefined): void { - this.sendMessageToOtherSide({ - type: RenderIframeMessageType.ON_SET_FRONTMATTER, - frontmatter: frontmatter - }) - } - public sendSetScrollState(scrollState: ScrollState): void { this.sendMessageToOtherSide({ type: RenderIframeMessageType.SET_SCROLL_STATE, @@ -126,6 +124,9 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< case RenderIframeMessageType.GET_WORD_COUNT: this.onGetWordCountHandler?.() return false + case RenderIframeMessageType.SET_FRONTMATTER_INFO: + this.onSetFrontmatterInfoHandler?.(renderMessage.frontmatterInfo) + return false } } } diff --git a/src/components/render-page/markdown-document.tsx b/src/components/render-page/markdown-document.tsx index c80e6f2af..cd4b136e2 100644 --- a/src/components/render-page/markdown-document.tsx +++ b/src/components/render-page/markdown-document.tsx @@ -7,7 +7,6 @@ import { TocAst } from 'markdown-it-toc-done-right' import React, { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' import useResizeObserver from 'use-resize-observer' -import { NoteFrontmatter } from '../editor-page/note-frontmatter/note-frontmatter' import { YamlArrayDeprecationAlert } from '../editor-page/renderer-pane/yaml-array-deprecation-alert' import { useSyncedScrolling } from '../editor-page/synced-scroll/hooks/use-synced-scrolling' import { ScrollProps } from '../editor-page/synced-scroll/scroll-props' @@ -17,10 +16,11 @@ import './markdown-document.scss' import { WidthBasedTableOfContents } from './width-based-table-of-contents' import { ShowIf } from '../common/show-if/show-if' import { useApplicationState } from '../../hooks/common/use-application-state' +import { RendererFrontmatterInfo } from '../common/note-frontmatter/types' +import { InvalidYamlAlert } from '../markdown-renderer/invalid-yaml-alert' export interface RendererProps extends ScrollProps { onFirstHeadingChange?: (firstHeading: string | undefined) => void - onFrontmatterChange?: (frontmatter: NoteFrontmatter | undefined) => void onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void documentRenderPaneRef?: MutableRefObject markdownContent: string @@ -33,13 +33,13 @@ export interface MarkdownDocumentProps extends RendererProps { additionalRendererClasses?: string disableToc?: boolean baseUrl: string + frontmatterInfo?: RendererFrontmatterInfo } export const MarkdownDocument: React.FC = ({ additionalOuterContainerClasses, additionalRendererClasses, onFirstHeadingChange, - onFrontmatterChange, onMakeScrollSource, onTaskCheckedChange, baseUrl, @@ -48,7 +48,8 @@ export const MarkdownDocument: React.FC = ({ onScroll, scrollState, onHeightChange, - disableToc + disableToc, + frontmatterInfo }) => { const rendererRef = useRef(null) const rendererSize = useResizeObserver({ ref: rendererRef.current }) @@ -85,19 +86,20 @@ export const MarkdownDocument: React.FC = ({ onMouseEnter={onMakeScrollSource}>
- + +
diff --git a/src/components/render-page/rendering-message.ts b/src/components/render-page/rendering-message.ts index c2fdd17dc..34270bd2b 100644 --- a/src/components/render-page/rendering-message.ts +++ b/src/components/render-page/rendering-message.ts @@ -3,8 +3,8 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { NoteFrontmatter } from '../editor-page/note-frontmatter/note-frontmatter' import { ScrollState } from '../editor-page/synced-scroll/scroll-props' +import { RendererFrontmatterInfo } from '../common/note-frontmatter/types' export enum RenderIframeMessageType { SET_MARKDOWN_CONTENT = 'SET_MARKDOWN_CONTENT', @@ -14,12 +14,12 @@ export enum RenderIframeMessageType { ON_FIRST_HEADING_CHANGE = 'ON_FIRST_HEADING_CHANGE', SET_SCROLL_SOURCE_TO_RENDERER = 'SET_SCROLL_SOURCE_TO_RENDERER', SET_SCROLL_STATE = 'SET_SCROLL_STATE', - ON_SET_FRONTMATTER = 'ON_SET_FRONTMATTER', IMAGE_CLICKED = 'IMAGE_CLICKED', ON_HEIGHT_CHANGE = 'ON_HEIGHT_CHANGE', SET_BASE_CONFIGURATION = 'SET_BASE_CONFIGURATION', GET_WORD_COUNT = 'GET_WORD_COUNT', - ON_WORD_COUNT_CALCULATED = 'ON_WORD_COUNT_CALCULATED' + ON_WORD_COUNT_CALCULATED = 'ON_WORD_COUNT_CALCULATED', + SET_FRONTMATTER_INFO = 'SET_FRONTMATTER_INFO' } export interface RendererToEditorSimpleMessage { @@ -72,9 +72,9 @@ export interface OnFirstHeadingChangeMessage { firstHeading: string | undefined } -export interface OnFrontmatterChangeMessage { - type: RenderIframeMessageType.ON_SET_FRONTMATTER - frontmatter: NoteFrontmatter | undefined +export interface SetFrontmatterInfoMessage { + type: RenderIframeMessageType.SET_FRONTMATTER_INFO + frontmatterInfo: RendererFrontmatterInfo } export interface OnHeightChangeMessage { @@ -93,12 +93,12 @@ export type EditorToRendererIframeMessage = | SetScrollStateMessage | SetBaseUrlMessage | GetWordCountMessage + | SetFrontmatterInfoMessage export type RendererToEditorIframeMessage = | RendererToEditorSimpleMessage | OnFirstHeadingChangeMessage | OnTaskCheckboxChangeMessage - | OnFrontmatterChangeMessage | SetScrollStateMessage | ImageClickedMessage | OnHeightChangeMessage diff --git a/src/redux/index.ts b/src/redux/index.ts index d53fe29b3..e284699e4 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -15,7 +15,7 @@ import { DarkModeConfigReducer } from './dark-mode/reducers' import { DarkModeConfig } from './dark-mode/types' import { EditorConfigReducer } from './editor/reducers' import { EditorConfig } from './editor/types' -import { NoteDetailsReducer } from './note-details/reducers' +import { NoteDetailsReducer } from './note-details/reducer' import { NoteDetails } from './note-details/types' import { UserReducer } from './user/reducers' import { OptionalUserState } from './user/types' diff --git a/src/redux/note-details/initial-state.ts b/src/redux/note-details/initial-state.ts new file mode 100644 index 000000000..f08ce184d --- /dev/null +++ b/src/redux/note-details/initial-state.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { NoteDetails } from './types' +import { DateTime } from 'luxon' +import { NoteTextDirection, NoteType } from '../../components/common/note-frontmatter/types' + +export const initialState: NoteDetails = { + documentContent: '', + markdownContent: '', + rawFrontmatter: '', + frontmatterRendererInfo: { + frontmatterInvalid: false, + deprecatedSyntax: false, + offsetLines: 0 + }, + id: '', + createTime: DateTime.fromSeconds(0), + lastChange: { + timestamp: DateTime.fromSeconds(0), + userName: '' + }, + alias: '', + viewCount: 0, + authorship: [], + noteTitle: '', + firstHeading: '', + frontmatter: { + title: '', + description: '', + tags: [], + deprecatedTagsSyntax: false, + robots: '', + lang: 'en', + dir: NoteTextDirection.LTR, + breaks: true, + GA: '', + disqus: '', + type: NoteType.DOCUMENT, + opengraph: new Map() + } +} diff --git a/src/redux/note-details/methods.ts b/src/redux/note-details/methods.ts index f47ceb6ec..cabf50963 100644 --- a/src/redux/note-details/methods.ts +++ b/src/redux/note-details/methods.ts @@ -6,31 +6,40 @@ import { store } from '..' import { NoteDto } from '../../api/notes/types' -import { NoteFrontmatter } from '../../components/editor-page/note-frontmatter/note-frontmatter' -import { initialState } from './reducers' import { NoteDetailsActionType, - SetCheckboxInMarkdownContentAction, - SetNoteDetailsAction, SetNoteDetailsFromServerAction, - SetNoteFrontmatterFromRenderingAction, - UpdateNoteTitleByFirstHeadingAction + SetNoteDocumentContentAction, + UpdateNoteTitleByFirstHeadingAction, + UpdateTaskListCheckboxAction } from './types' -export const setNoteMarkdownContent = (content: string): void => { +/** + * Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part. + * @param content The note content as it is written inside the editor pane. + */ +export const setNoteContent = (content: string): void => { store.dispatch({ type: NoteDetailsActionType.SET_DOCUMENT_CONTENT, - content - } as SetNoteDetailsAction) + content: content + } as SetNoteDocumentContentAction) } +/** + * Sets the note metadata for the current note from an API response DTO to the redux. + * @param apiResponse The NoteDTO received from the API to store into redux. + */ export const setNoteDataFromServer = (apiResponse: NoteDto): void => { store.dispatch({ type: NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER, - note: apiResponse + dto: apiResponse } as SetNoteDetailsFromServerAction) } +/** + * Updates the note title in the redux by the first heading found in the markdown content. + * @param firstHeading The content of the first heading found in the markdown content. + */ export const updateNoteTitleByFirstHeading = (firstHeading?: string): void => { store.dispatch({ type: NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING, @@ -38,20 +47,15 @@ export const updateNoteTitleByFirstHeading = (firstHeading?: string): void => { } as UpdateNoteTitleByFirstHeadingAction) } -export const setNoteFrontmatter = (frontmatter: NoteFrontmatter | undefined): void => { - if (!frontmatter) { - frontmatter = initialState.frontmatter - } +/** + * 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.SET_NOTE_FRONTMATTER, - frontmatter: frontmatter - } as SetNoteFrontmatterFromRenderingAction) -} - -export const setCheckboxInMarkdownContent = (lineInMarkdown: number, checked: boolean): void => { - store.dispatch({ - type: NoteDetailsActionType.SET_CHECKBOX_IN_MARKDOWN_CONTENT, - checked: checked, - lineInMarkdown: lineInMarkdown - } as SetCheckboxInMarkdownContentAction) + type: NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX, + checkboxChecked: checked, + changedLine: lineInDocumentContent + } as UpdateTaskListCheckboxAction) } diff --git a/src/redux/note-details/reducer.ts b/src/redux/note-details/reducer.ts new file mode 100644 index 000000000..496c21ebe --- /dev/null +++ b/src/redux/note-details/reducer.ts @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Reducer } from 'redux' +import { PresentFrontmatterExtractionResult } from '../../components/common/note-frontmatter/types' +import { NoteFrontmatter } from '../../components/common/note-frontmatter/note-frontmatter' +import { NoteDetails, NoteDetailsActions, NoteDetailsActionType } from './types' +import { extractFrontmatter } from '../../components/common/note-frontmatter/extract-frontmatter' +import { NoteDto } from '../../api/notes/types' +import { initialState } from './initial-state' +import { DateTime } from 'luxon' + +export const NoteDetailsReducer: Reducer = ( + state: NoteDetails = initialState, + action: NoteDetailsActions +) => { + switch (action.type) { + case NoteDetailsActionType.SET_DOCUMENT_CONTENT: + return buildStateFromDocumentContentUpdate(state, action.content) + case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING: + return buildStateFromFirstHeadingUpdate(state, action.firstHeading) + case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER: + return buildStateFromServerDto(action.dto) + case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX: + return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked) + default: + return state + } +} + +const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]])( .*)/ + +/** + * 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 buildStateFromDocumentContentUpdate(newState, newState.documentContent) +} + +/** + * 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.documentContent.split('\n') + const results = TASK_REGEX.exec(lines[changedLine]) + if (results) { + const before = results[1] + const after = results[3] + lines[changedLine] = `${before}[${checkboxChecked ? 'x' : ' '}]${after}` + return buildStateFromDocumentContentUpdate(state, lines.join('\n')) + } + return state +} + +/** + * Builds a {@link NoteDetails} redux state from a fresh document content. + * @param state The previous redux state. + * @param documentContent The fresh document content consisting of the frontmatter and markdown part. + * @return An updated {@link NoteDetails} redux state. + */ +const buildStateFromDocumentContentUpdate = (state: NoteDetails, documentContent: string): NoteDetails => { + const frontmatterExtraction = extractFrontmatter(documentContent) + if (!frontmatterExtraction.frontmatterPresent) { + return { + ...state, + documentContent: documentContent, + markdownContent: documentContent, + rawFrontmatter: '', + frontmatter: initialState.frontmatter, + frontmatterRendererInfo: initialState.frontmatterRendererInfo + } + } + return buildStateFromFrontmatterUpdate( + { + ...state, + documentContent: documentContent, + markdownContent: documentContent.split('\n').slice(frontmatterExtraction.frontmatterLines).join('\n') + }, + frontmatterExtraction + ) +} + +/** + * 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.rawFrontmatterText === state.rawFrontmatter) { + return state + } + try { + const frontmatter = NoteFrontmatter.createFromYaml(frontmatterExtraction.rawFrontmatterText) + return { + ...state, + rawFrontmatter: frontmatterExtraction.rawFrontmatterText, + frontmatter: frontmatter, + noteTitle: generateNoteTitle(frontmatter), + frontmatterRendererInfo: { + offsetLines: frontmatterExtraction.frontmatterLines, + deprecatedSyntax: frontmatter.deprecatedTagsSyntax, + frontmatterInvalid: false + } + } + } catch (e) { + return { + ...state, + rawFrontmatter: frontmatterExtraction.rawFrontmatterText, + frontmatter: initialState.frontmatter, + frontmatterRendererInfo: { + offsetLines: frontmatterExtraction.frontmatterLines, + deprecatedSyntax: false, + frontmatterInvalid: true + } + } + } +} + +/** + * 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 ?? 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 { + documentContent: note.content, + markdownContent: '', + rawFrontmatter: '', + frontmatterRendererInfo: { + frontmatterInvalid: false, + deprecatedSyntax: false, + offsetLines: 0 + }, + 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 + } +} diff --git a/src/redux/note-details/reducers.ts b/src/redux/note-details/reducers.ts deleted file mode 100644 index faa1bb9f6..000000000 --- a/src/redux/note-details/reducers.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { DateTime } from 'luxon' -import { Reducer } from 'redux' -import { - NoteFrontmatter, - NoteTextDirection, - NoteType -} from '../../components/editor-page/note-frontmatter/note-frontmatter' -import { NoteDetails, NoteDetailsActions, NoteDetailsActionType } from './types' -import { noteDtoToNoteDetails } from '../../api/notes/dto-methods' - -export const initialState: NoteDetails = { - markdownContent: '', - id: '', - createTime: DateTime.fromSeconds(0), - lastChange: { - timestamp: DateTime.fromSeconds(0), - userName: '' - }, - alias: '', - viewCount: 0, - authorship: [], - noteTitle: '', - firstHeading: '', - frontmatter: { - title: '', - description: '', - tags: [], - deprecatedTagsSyntax: false, - robots: '', - lang: 'en', - dir: NoteTextDirection.LTR, - breaks: true, - GA: '', - disqus: '', - type: NoteType.DOCUMENT, - opengraph: new Map() - } -} - -export const NoteDetailsReducer: Reducer = ( - state: NoteDetails = initialState, - action: NoteDetailsActions -) => { - switch (action.type) { - case NoteDetailsActionType.SET_DOCUMENT_CONTENT: - return { - ...state, - markdownContent: action.content - } - case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING: - return { - ...state, - firstHeading: action.firstHeading, - noteTitle: generateNoteTitle(state.frontmatter, action.firstHeading) - } - case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER: - return noteDtoToNoteDetails(action.note) - case NoteDetailsActionType.SET_NOTE_FRONTMATTER: - return { - ...state, - frontmatter: action.frontmatter, - noteTitle: generateNoteTitle(action.frontmatter, state.firstHeading) - } - case NoteDetailsActionType.SET_CHECKBOX_IN_MARKDOWN_CONTENT: - return { - ...state, - markdownContent: setCheckboxInMarkdownContent(state.markdownContent, action.lineInMarkdown, action.checked) - } - default: - return state - } -} - -const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]])( .*)/ -const setCheckboxInMarkdownContent = (markdownContent: string, lineInMarkdown: number, checked: boolean): string => { - const lines = markdownContent.split('\n') - const results = TASK_REGEX.exec(lines[lineInMarkdown]) - if (results) { - const before = results[1] - const after = results[3] - lines[lineInMarkdown] = `${before}[${checked ? 'x' : ' '}]${after}` - return lines.join('\n') - } - return markdownContent -} - -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 ?? firstHeading ?? '').trim() - } -} diff --git a/src/redux/note-details/types.ts b/src/redux/note-details/types.ts index 730fa4e4b..298473836 100644 --- a/src/redux/note-details/types.ts +++ b/src/redux/note-details/types.ts @@ -6,24 +6,30 @@ import { DateTime } from 'luxon' import { Action } from 'redux' -import { NoteFrontmatter } from '../../components/editor-page/note-frontmatter/note-frontmatter' +import { NoteFrontmatter } from '../../components/common/note-frontmatter/note-frontmatter' import { NoteDto } from '../../api/notes/types' +import { RendererFrontmatterInfo } from '../../components/common/note-frontmatter/types' export enum NoteDetailsActionType { - SET_DOCUMENT_CONTENT = 'note-details/set', + SET_DOCUMENT_CONTENT = 'note-details/content/set', SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set', - SET_NOTE_FRONTMATTER = 'note-details/frontmatter/set', UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading', - SET_CHECKBOX_IN_MARKDOWN_CONTENT = 'note-details/toggle-checkbox-in-markdown-content' + UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox' } - interface LastChange { userName: string timestamp: DateTime } +/** + * Redux state containing the currently loaded note with its content and metadata. + */ export interface NoteDetails { + documentContent: string markdownContent: string + rawFrontmatter: string + frontmatter: NoteFrontmatter + frontmatterRendererInfo: RendererFrontmatterInfo id: string createTime: DateTime lastChange: LastChange @@ -32,38 +38,43 @@ export interface NoteDetails { authorship: string[] noteTitle: string firstHeading?: string - frontmatter: NoteFrontmatter } export type NoteDetailsActions = - | SetNoteDetailsAction + | SetNoteDocumentContentAction | SetNoteDetailsFromServerAction | UpdateNoteTitleByFirstHeadingAction - | SetNoteFrontmatterFromRenderingAction - | SetCheckboxInMarkdownContentAction + | UpdateTaskListCheckboxAction -export interface SetNoteDetailsAction extends Action { +/** + * Action for updating the document content of the currently loaded note. + */ +export interface SetNoteDocumentContentAction extends Action { type: NoteDetailsActionType.SET_DOCUMENT_CONTENT content: string } +/** + * Action for overwriting the current state with the data received from the API. + */ export interface SetNoteDetailsFromServerAction extends Action { type: NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER - note: NoteDto + dto: NoteDto } +/** + * Action for updating the note title of the currently loaded note by using frontmatter data or the first heading. + */ export interface UpdateNoteTitleByFirstHeadingAction extends Action { type: NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING firstHeading?: string } -export interface SetNoteFrontmatterFromRenderingAction extends Action { - type: NoteDetailsActionType.SET_NOTE_FRONTMATTER - frontmatter: NoteFrontmatter -} - -export interface SetCheckboxInMarkdownContentAction extends Action { - type: NoteDetailsActionType.SET_CHECKBOX_IN_MARKDOWN_CONTENT - lineInMarkdown: number - checked: boolean +/** + * 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 { + type: NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX + changedLine: number + checkboxChecked: boolean } diff --git a/yarn.lock b/yarn.lock index 17abf76f4..3ef62e4fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9508,11 +9508,6 @@ markdown-it-footnote@3.0.3: resolved "https://registry.yarnpkg.com/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz#e0e4c0d67390a4c5f0c75f73be605c7c190ca4d8" integrity sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w== -markdown-it-front-matter@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/markdown-it-front-matter/-/markdown-it-front-matter-0.2.3.tgz#d6fa0f4b362e02086dd4ce8219fadf3f4c9cfa37" - integrity sha512-s9+rcClLmZsZc3YL8Awjg/YO/VdphlE20LJ9Bx5a8RAFLI5a1vq6Mll8kOzG6w/wy8yhFLBupaa6Mfd60GATkA== - markdown-it-imsize@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/markdown-it-imsize/-/markdown-it-imsize-2.0.1.tgz#cca0427905d05338a247cb9ca9d968c5cddd5170"