diff --git a/cypress/.eslintrc.json b/cypress/.eslintrc.json index 4d2532e9f..138ca022b 100644 --- a/cypress/.eslintrc.json +++ b/cypress/.eslintrc.json @@ -15,7 +15,8 @@ "rules": { "@typescript-eslint/no-unused-expressions": 0, "no-unused-expressions": 0, - "chai-friendly/no-unused-expressions": 2 + "chai-friendly/no-unused-expressions": 2, + "@typescript-eslint/no-namespace": 0 }, "env": { "cypress/globals": true diff --git a/cypress/integration/autocompletion.spec.ts b/cypress/integration/autocompletion.spec.ts index 285d7153b..44d5d3cc8 100644 --- a/cypress/integration/autocompletion.spec.ts +++ b/cypress/integration/autocompletion.spec.ts @@ -15,7 +15,7 @@ describe('Autocompletion', () => { describe('code block', () => { it('via Enter', () => { - cy.codemirrorFill('```') + cy.setCodemirrorContent('```') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -29,7 +29,7 @@ describe('Autocompletion', () => { cy.getMarkdownBody().find('.code-highlighter').should('exist') }) it('via doubleclick', () => { - cy.codemirrorFill('```') + cy.setCodemirrorContent('```') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -45,7 +45,7 @@ describe('Autocompletion', () => { describe('container', () => { it('via Enter', () => { - cy.codemirrorFill(':::') + cy.setCodemirrorContent(':::') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -61,7 +61,7 @@ describe('Autocompletion', () => { .should('exist') }) it('via doubleclick', () => { - cy.codemirrorFill(':::') + cy.setCodemirrorContent(':::') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -80,7 +80,7 @@ describe('Autocompletion', () => { describe('emoji', () => { describe('normal emoji', () => { it('via Enter', () => { - cy.codemirrorFill(':hedg') + cy.setCodemirrorContent(':hedg') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -91,7 +91,7 @@ describe('Autocompletion', () => { .should('have.text', ':hedgehog:') }) it('via doubleclick', () => { - cy.codemirrorFill(':hedg') + cy.setCodemirrorContent(':hedg') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -104,7 +104,7 @@ describe('Autocompletion', () => { describe('fork-awesome-icon', () => { it('via Enter', () => { - cy.codemirrorFill(':fa-face') + cy.setCodemirrorContent(':fa-face') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -115,7 +115,7 @@ describe('Autocompletion', () => { .should('have.text', ':fa-facebook:') }) it('via doubleclick', () => { - cy.codemirrorFill(':fa-face') + cy.setCodemirrorContent(':fa-face') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -129,7 +129,7 @@ describe('Autocompletion', () => { describe('header', () => { it('via Enter', () => { - cy.codemirrorFill('#') + cy.setCodemirrorContent('#') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -143,7 +143,7 @@ describe('Autocompletion', () => { .should('have.text', '\n ') }) it('via doubleclick', () => { - cy.codemirrorFill('#') + cy.setCodemirrorContent('#') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -159,7 +159,7 @@ describe('Autocompletion', () => { describe('images', () => { it('via Enter', () => { - cy.codemirrorFill('!') + cy.setCodemirrorContent('!') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -175,7 +175,7 @@ describe('Autocompletion', () => { .should('have.attr', 'title', 'title') }) it('via doubleclick', () => { - cy.codemirrorFill('!') + cy.setCodemirrorContent('!') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -193,7 +193,7 @@ describe('Autocompletion', () => { describe('links', () => { it('via Enter', () => { - cy.codemirrorFill('[') + cy.setCodemirrorContent('[') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -209,7 +209,7 @@ describe('Autocompletion', () => { .should('have.attr', 'title', 'title') }) it('via doubleclick', () => { - cy.codemirrorFill('[') + cy.setCodemirrorContent('[') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -227,7 +227,7 @@ describe('Autocompletion', () => { describe('pdf', () => { it('via Enter', () => { - cy.codemirrorFill('{') + cy.setCodemirrorContent('{') cy.get('.CodeMirror-hints') .should('exist') cy.get('@codeinput') @@ -241,7 +241,7 @@ describe('Autocompletion', () => { .should('exist') }) it('via doubleclick', () => { - cy.codemirrorFill('{') + cy.setCodemirrorContent('{') cy.get('.CodeMirror-hints > li') .first() .dblclick() @@ -257,7 +257,7 @@ describe('Autocompletion', () => { describe('collapsable blocks', () => { it('via Enter', () => { - cy.codemirrorFill(' { .should('exist') }) it('via doubleclick', () => { - cy.codemirrorFill(' li') .first() .dblclick() diff --git a/cypress/integration/diagrams.spec.ts b/cypress/integration/diagrams.spec.ts index 186810da3..ceeb03697 100644 --- a/cypress/integration/diagrams.spec.ts +++ b/cypress/integration/diagrams.spec.ts @@ -10,7 +10,7 @@ describe('Diagram codeblock ', () => { }) it('renders markmap', () => { - cy.codemirrorFill('```markmap\n- pro\n- contra\n```') + cy.setCodemirrorContent('```markmap\n- pro\n- contra\n```') cy.getMarkdownBody() .find('[data-cy=markmap]') .children() @@ -18,7 +18,7 @@ describe('Diagram codeblock ', () => { }) it('renders vega-lite', () => { - cy.codemirrorFill('```vega-lite\n{"$schema":"https://vega.github.io/schema/vega-lite/v4.json","data":{"values":[{"a":"","b":28}]},"mark":"bar","encoding":{"x":{"field":"a"},"y":{"field":"b"}}}\n```') + cy.setCodemirrorContent('```vega-lite\n{"$schema":"https://vega.github.io/schema/vega-lite/v4.json","data":{"values":[{"a":"","b":28}]},"mark":"bar","encoding":{"x":{"field":"a"},"y":{"field":"b"}}}\n```') cy.getMarkdownBody() .find('.vega-embed') .children() @@ -26,7 +26,7 @@ describe('Diagram codeblock ', () => { }) it('renders graphviz', () => { - cy.codemirrorFill('```graphviz\ngraph {\na -- b\n}\n```') + cy.setCodemirrorContent('```graphviz\ngraph {\na -- b\n}\n```') cy.getMarkdownBody() .find('[data-cy=graphviz]') .children() @@ -34,7 +34,7 @@ describe('Diagram codeblock ', () => { }) it('renders mermaid', () => { - cy.codemirrorFill('```mermaid\ngraph TD;\n A-->B;\n```') + cy.setCodemirrorContent('```mermaid\ngraph TD;\n A-->B;\n```') cy.getMarkdownBody() .find('.mermaid') .children() @@ -42,7 +42,7 @@ describe('Diagram codeblock ', () => { }) it('renders flowcharts', () => { - cy.codemirrorFill('```flow\nst=>start: Start\ne=>end: End\nst->e\n```') + cy.setCodemirrorContent('```flow\nst=>start: Start\ne=>end: End\nst->e\n```') cy.getMarkdownBody() .find('[data-cy=flowchart]') .children() @@ -50,7 +50,7 @@ describe('Diagram codeblock ', () => { }) it('renders abc scores', () => { - cy.codemirrorFill('```abc\nM:4/4\nK:G\n|:GABc dedB:|\n```') + cy.setCodemirrorContent('```abc\nM:4/4\nK:G\n|:GABc dedB:|\n```') cy.getMarkdownBody() .find('.abcjs-score') .children() @@ -58,14 +58,14 @@ describe('Diagram codeblock ', () => { }) it('renders csv as table', () => { - cy.codemirrorFill('```csv delimiter=; header\na;b;c;d\n1;2;3;4\n```') + cy.setCodemirrorContent('```csv delimiter=; header\na;b;c;d\n1;2;3;4\n```') cy.getMarkdownBody() .find('.csv-html-table') .should('be.visible') }) it('renders plantuml', () => { - cy.codemirrorFill('```plantuml\nclass Example\n```') + cy.setCodemirrorContent('```plantuml\nclass Example\n```') cy.getMarkdownBody() .find('img') // PlantUML uses base64 encoded version of zip-deflated PlantUML code in the request URL. diff --git a/cypress/integration/documentTitle.spec.ts b/cypress/integration/documentTitle.spec.ts index 8d7745708..ebd019f30 100644 --- a/cypress/integration/documentTitle.spec.ts +++ b/cypress/integration/documentTitle.spec.ts @@ -16,19 +16,19 @@ describe('Document Title', () => { describe('title should be yaml metadata title', () => { it('just yaml metadata title', () => { - cy.codemirrorFill(`---\ntitle: ${ title }\n---`) + cy.setCodemirrorContent(`---\ntitle: ${ title }\n---`) cy.title() .should('eq', `${ title } - HedgeDoc @ ${ branding.name }`) }) it('yaml metadata title and opengraph title', () => { - cy.codemirrorFill(`---\ntitle: ${ title }\nopengraph:\n title: False title\n---`) + cy.setCodemirrorContent(`---\ntitle: ${ title }\nopengraph:\n title: False title\n---`) cy.title() .should('eq', `${ title } - HedgeDoc @ ${ branding.name }`) }) it('yaml metadata title, opengraph title and first heading', () => { - cy.codemirrorFill(`---\ntitle: ${ title }\nopengraph:\n title: False title\n---\n# a first title`) + cy.setCodemirrorContent(`---\ntitle: ${ title }\nopengraph:\n title: False title\n---\n# a first title`) cy.title() .should('eq', `${ title } - HedgeDoc @ ${ branding.name }`) }) @@ -36,13 +36,13 @@ describe('Document Title', () => { describe('title should be opengraph title', () => { it('just opengraph title', () => { - cy.codemirrorFill(`---\nopengraph:\n title: ${ title }\n---`) + cy.setCodemirrorContent(`---\nopengraph:\n title: ${ title }\n---`) cy.title() .should('eq', `${ title } - HedgeDoc @ ${ branding.name }`) }) it('opengraph title and first heading', () => { - cy.codemirrorFill(`---\nopengraph:\n title: ${ title }\n---\n# a first title`) + cy.setCodemirrorContent(`---\nopengraph:\n title: ${ title }\n---\n# a first title`) cy.title() .should('eq', `${ title } - HedgeDoc @ ${ branding.name }`) }) @@ -50,44 +50,44 @@ describe('Document Title', () => { describe('title should be first heading', () => { it('just first heading', () => { - cy.codemirrorFill(`# ${ title }`) + cy.setCodemirrorContent(`# ${ title }`) cy.title() .should('eq', `${ title } - HedgeDoc @ ${ branding.name }`) }) it('just first heading with alt-text instead of image', () => { - cy.codemirrorFill(`# ${ title } ![abc](https://dummyimage.com/48)`) + cy.setCodemirrorContent(`# ${ title } ![abc](https://dummyimage.com/48)`) cy.title() .should('eq', `${ title } abc - HedgeDoc @ ${ branding.name }`) }) it('just first heading without link syntax', () => { - cy.codemirrorFill(`# ${ title } [link](https://hedgedoc.org)`) + cy.setCodemirrorContent(`# ${ title } [link](https://hedgedoc.org)`) cy.title() .should('eq', `${ title } link - HedgeDoc @ ${ branding.name }`) }) it('markdown syntax removed first', () => { - cy.codemirrorFill(`# ${ title } 1*2*3 4*5**`) + cy.setCodemirrorContent(`# ${ title } 1*2*3 4*5**`) cy.title() .should('eq', `${ title } 123 4*5** - HedgeDoc @ ${ branding.name }`) }) it('markdown syntax removed second', () => { - cy.codemirrorFill(`# ${ title } **1 2*`) + cy.setCodemirrorContent(`# ${ title } **1 2*`) cy.title() .should('eq', `${ title } *1 2 - HedgeDoc @ ${ branding.name }`) }) it('markdown syntax removed third', () => { - cy.codemirrorFill(`# ${ title } _asd_`) + cy.setCodemirrorContent(`# ${ title } _asd_`) cy.title() .should('eq', `${ title } asd - HedgeDoc @ ${ branding.name }`) }) it('katex code looks right', () => { - cy.codemirrorFill(`# $\\alpha$-foo`) - cy.getMarkdownRenderer() + cy.setCodemirrorContent(`# $\\alpha$-foo`) + cy.getIframeBody() .find('h1') .should('contain', 'α') cy.get('.CodeMirror textarea') diff --git a/cypress/integration/emoji.spec.ts b/cypress/integration/emoji.spec.ts index d59991d0c..33d4c7984 100644 --- a/cypress/integration/emoji.spec.ts +++ b/cypress/integration/emoji.spec.ts @@ -13,19 +13,19 @@ describe('emojis', () => { }) it('renders an emoji shortcode', () => { - cy.codemirrorFill(':hedgehog:') + cy.setCodemirrorContent(':hedgehog:') cy.getMarkdownBody() .should('have.text', HEDGEHOG_UNICODE_CHARACTER) }) it('renders an emoji unicode character', () => { - cy.codemirrorFill(HEDGEHOG_UNICODE_CHARACTER) + cy.setCodemirrorContent(HEDGEHOG_UNICODE_CHARACTER) cy.getMarkdownBody() .should('have.text', HEDGEHOG_UNICODE_CHARACTER) }) it('renders an fork awesome icon', () => { - cy.codemirrorFill(':fa-matrix-org:') + cy.setCodemirrorContent(':fa-matrix-org:') cy.getMarkdownBody() .find('i.fa.fa-matrix-org') .should('be.visible') diff --git a/cypress/integration/export.spec.ts b/cypress/integration/export.spec.ts index b76a44160..c24f0b1cb 100644 --- a/cypress/integration/export.spec.ts +++ b/cypress/integration/export.spec.ts @@ -10,7 +10,7 @@ describe('Export', () => { beforeEach(() => { cy.visitTestEditor() - cy.codemirrorFill(testContent) + cy.setCodemirrorContent(testContent) }) it('Markdown', () => { diff --git a/cypress/integration/fileUpload.spec.ts b/cypress/integration/fileUpload.spec.ts index f02059d42..dd5c373cf 100644 --- a/cypress/integration/fileUpload.spec.ts +++ b/cypress/integration/fileUpload.spec.ts @@ -13,7 +13,7 @@ describe('File upload', () => { it('doesn\'t prevent drag\'n\'drop of plain text', () => { const dataTransfer = new DataTransfer() - cy.codemirrorFill('line 1\nline 2\ndragline') + cy.setCodemirrorContent('line 1\nline 2\ndragline') cy.get('.CodeMirror') .click() cy.get('.CodeMirror-line > span') diff --git a/cypress/integration/highlightedCodeBlock.spec.ts b/cypress/integration/highlightedCodeBlock.spec.ts index b3b1b1468..3aacc4967 100644 --- a/cypress/integration/highlightedCodeBlock.spec.ts +++ b/cypress/integration/highlightedCodeBlock.spec.ts @@ -15,7 +15,7 @@ describe('Code', () => { describe('with just the language', () => { it('doesn\'t show a gutter', () => { - cy.codemirrorFill('```javascript \nlet x = 0\n```') + cy.setCodemirrorContent('```javascript \nlet x = 0\n```') findHljsCodeBlock() .should('not.have.class', 'showGutter') @@ -26,7 +26,7 @@ describe('Code', () => { describe('and line wrapping', () => { it('doesn\'t show a gutter', () => { - cy.codemirrorFill('```javascript! \nlet x = 0\n```') + cy.setCodemirrorContent('```javascript! \nlet x = 0\n```') findHljsCodeBlock() .should('not.have.class', 'showGutter') .should('have.class', 'wrapLines') @@ -40,7 +40,7 @@ describe('Code', () => { describe('with the language and show gutter', () => { it('shows the correct line number', () => { - cy.codemirrorFill('```javascript= \nlet x = 0\n```') + cy.setCodemirrorContent('```javascript= \nlet x = 0\n```') findHljsCodeBlock() .should('have.class', 'showGutter') @@ -53,7 +53,7 @@ describe('Code', () => { describe('and line wrapping', () => { it('shows the correct line number', () => { - cy.codemirrorFill('```javascript=! \nlet x = 0\n```') + cy.setCodemirrorContent('```javascript=! \nlet x = 0\n```') findHljsCodeBlock() .should('have.class', 'showGutter') .should('have.class', 'wrapLines') @@ -69,7 +69,7 @@ describe('Code', () => { describe('with the language, show gutter with a start number', () => { it('shows the correct line number', () => { - cy.codemirrorFill('```javascript=100 \nlet x = 0\n```') + cy.setCodemirrorContent('```javascript=100 \nlet x = 0\n```') findHljsCodeBlock() .should('have.class', 'showGutter') @@ -81,7 +81,7 @@ describe('Code', () => { }) it('shows the correct line number and continues in another codeblock', () => { - cy.codemirrorFill('```javascript=100 \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n') + cy.setCodemirrorContent('```javascript=100 \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n') findHljsCodeBlock() .should('have.class', 'showGutter') .first() @@ -108,7 +108,7 @@ describe('Code', () => { describe('and line wrapping', () => { it('shows the correct line number', () => { - cy.codemirrorFill('```javascript=100! \nlet x = 0\n```') + cy.setCodemirrorContent('```javascript=100! \nlet x = 0\n```') findHljsCodeBlock() .should('have.class', 'showGutter') .should('have.class', 'wrapLines') @@ -120,7 +120,7 @@ describe('Code', () => { }) it('shows the correct line number and continues in another codeblock', () => { - cy.codemirrorFill('```javascript=100! \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n') + cy.setCodemirrorContent('```javascript=100! \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n') findHljsCodeBlock() .should('have.class', 'showGutter') .should('have.class', 'wrapLines') @@ -149,7 +149,7 @@ describe('Code', () => { }) it('has a working copy button', () => { - cy.codemirrorFill('```javascript \nlet x = 0\n```') + cy.setCodemirrorContent('```javascript \nlet x = 0\n```') cy.get(`iframe[data-cy="documentIframe"]`) .then(($element: JQuery) => { @@ -162,7 +162,7 @@ describe('Code', () => { .as('copy') }) - cy.getMarkdownRenderer() + cy.getIframeBody() .find('[data-cy="copy-code-button"]') .click() diff --git a/cypress/integration/import.spec.ts b/cypress/integration/import.spec.ts index df75f90de..b19c983f7 100644 --- a/cypress/integration/import.spec.ts +++ b/cypress/integration/import.spec.ts @@ -24,7 +24,7 @@ describe('Import markdown file', () => { it('import on note with content', () => { - cy.codemirrorFill('test\nabc') + cy.setCodemirrorContent('test\nabc') cy.get('[data-cy="menu-import"]') .click() cy.get('[data-cy="menu-import-markdown"]') diff --git a/cypress/integration/linkEmbedder.spec.ts b/cypress/integration/linkEmbedder.spec.ts index 6b988bd2e..2084cba68 100644 --- a/cypress/integration/linkEmbedder.spec.ts +++ b/cypress/integration/linkEmbedder.spec.ts @@ -12,7 +12,7 @@ describe('Link gets replaced with embedding: ', () => { // TODO Add general testing of one-click-embedding component. The tests below just test a specific use of the component. it('GitHub Gist', () => { - cy.codemirrorFill('https://gist.github.com/schacon/1') + cy.setCodemirrorContent('https://gist.github.com/schacon/1') cy.getMarkdownBody() .find('.one-click-embedding.gist-frame') .click() @@ -22,7 +22,7 @@ describe('Link gets replaced with embedding: ', () => { }) it('YouTube', () => { - cy.codemirrorFill('https://www.youtube.com/watch?v=YE7VzlLtp-4') + cy.setCodemirrorContent('https://www.youtube.com/watch?v=YE7VzlLtp-4') cy.getMarkdownBody() .find('.one-click-embedding-preview') .should('have.attr', 'src', 'https://i.ytimg.com/vi/YE7VzlLtp-4/maxresdefault.jpg') @@ -44,7 +44,7 @@ describe('Link gets replaced with embedding: ', () => { }, body: '[{"thumbnail_large": "https://i.vimeocdn.com/video/503631401_640.jpg"}]' }) - cy.codemirrorFill('https://vimeo.com/23237102') + cy.setCodemirrorContent('https://vimeo.com/23237102') cy.getMarkdownBody() .find('.one-click-embedding-preview') .should('have.attr', 'src', 'https://i.vimeocdn.com/video/503631401_640.jpg') @@ -56,7 +56,7 @@ describe('Link gets replaced with embedding: ', () => { }) it('Asciinema', () => { - cy.codemirrorFill('https://asciinema.org/a/117928') + cy.setCodemirrorContent('https://asciinema.org/a/117928') cy.getMarkdownBody() .find('.one-click-embedding-preview') .should('have.attr', 'src', 'https://asciinema.org/a/117928.png') diff --git a/cypress/integration/linkSchemes.spec.ts b/cypress/integration/linkSchemes.spec.ts index 0ff1c008b..82c09275a 100644 --- a/cypress/integration/linkSchemes.spec.ts +++ b/cypress/integration/linkSchemes.spec.ts @@ -10,7 +10,7 @@ describe('markdown formatted links to', () => { }) it('external domains render as external link', () => { - cy.codemirrorFill('[external](https://hedgedoc.org/)') + cy.setCodemirrorContent('[external](https://hedgedoc.org/)') cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'https://hedgedoc.org/') @@ -19,28 +19,28 @@ describe('markdown formatted links to', () => { }) it('note anchor references render as anchor link', () => { - cy.codemirrorFill('[anchor](#anchor)') + cy.setCodemirrorContent('[anchor](#anchor)') cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'http://127.0.0.1:3001/n/test#anchor') }) it('internal pages render as internal link', () => { - cy.codemirrorFill('[internal](other-note)') + cy.setCodemirrorContent('[internal](other-note)') cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'http://127.0.0.1:3001/n/other-note') }) it('data URIs do not render', () => { - cy.codemirrorFill('[data](data:text/plain,evil)') + cy.setCodemirrorContent('[data](data:text/plain,evil)') cy.getMarkdownBody() .find('a') .should('not.exist') }) it('javascript URIs do not render', () => { - cy.codemirrorFill('[js](javascript:alert("evil"))') + cy.setCodemirrorContent('[js](javascript:alert("evil"))') cy.getMarkdownBody() .find('a') .should('not.exist') @@ -53,7 +53,7 @@ describe('HTML anchor element links to', () => { }) it('external domains render as external link', () => { - cy.codemirrorFill('external') + cy.setCodemirrorContent('external') cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'https://hedgedoc.org/') @@ -62,28 +62,28 @@ describe('HTML anchor element links to', () => { }) it('note anchor references render as anchor link', () => { - cy.codemirrorFill('anchor') + cy.setCodemirrorContent('anchor') cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'http://127.0.0.1:3001/n/test#anchor') }) it('internal pages render as internal link', () => { - cy.codemirrorFill('internal') + cy.setCodemirrorContent('internal') cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'http://127.0.0.1:3001/n/other-note') }) it('data URIs do not render', () => { - cy.codemirrorFill('data') + cy.setCodemirrorContent('data') cy.getMarkdownBody() .find('a') .should('not.exist') }) it('javascript URIs do not render', () => { - cy.codemirrorFill('js') + cy.setCodemirrorContent('js') cy.getMarkdownBody() .find('a') .should('not.exist') diff --git a/cypress/integration/maxLength.spec.ts b/cypress/integration/maxLength.spec.ts index 1aca4b986..7b307704b 100644 --- a/cypress/integration/maxLength.spec.ts +++ b/cypress/integration/maxLength.spec.ts @@ -20,22 +20,22 @@ describe('The status bar text length info', () => { }) it('color is set to "warning" on <= 100 characters remaining', () => { - cy.codemirrorFill(warningTestContent) + cy.setCodemirrorContent(warningTestContent) cy.get('.status-bar [data-cy="remainingCharacters"]') .should('have.class', 'text-warning') }) it('color is set to danger on <= 0 characters remaining', () => { - cy.codemirrorFill(dangerTestContent) + cy.setCodemirrorContent(dangerTestContent) cy.get('.status-bar [data-cy="remainingCharacters"]') .should('have.class', 'text-danger') }) it('shows a warning and opens a modal', () => { - cy.codemirrorFill(tooMuchTestContent) + cy.setCodemirrorContent(tooMuchTestContent) cy.get('[data-cy="limitReachedModal"]') .should('be.visible') - cy.getMarkdownRenderer() + cy.getIframeBody() .find('[data-cy="limitReachedMessage"]') .should('be.visible') }) diff --git a/cypress/integration/quote-extra.spec.ts b/cypress/integration/quote-extra.spec.ts index 29b0d25ae..8e81adb8e 100644 --- a/cypress/integration/quote-extra.spec.ts +++ b/cypress/integration/quote-extra.spec.ts @@ -11,7 +11,7 @@ describe('Quote extra tags', function () { describe('Name quote tag', () => { it('renders correctly', () => { - cy.codemirrorFill('[name=testy mctestface]') + cy.setCodemirrorContent('[name=testy mctestface]') cy.getMarkdownBody() .find('.quote-extra') @@ -28,7 +28,7 @@ describe('Quote extra tags', function () { describe('Time quote tag', () => { it('renders correctly', () => { - cy.codemirrorFill(`[time=always]`) + cy.setCodemirrorContent(`[time=always]`) cy.getMarkdownBody() .find('.quote-extra') @@ -45,7 +45,7 @@ describe('Quote extra tags', function () { describe('Color quote tag', () => { it('renders correctly', () => { - cy.codemirrorFill(`[color=#b51f08]`) + cy.setCodemirrorContent(`[color=#b51f08]`) cy.getMarkdownBody() .find('.quote-extra') @@ -60,7 +60,7 @@ describe('Quote extra tags', function () { }) it('doesn\'t render in a blockquote and dyes the blockquote border', () => { - cy.codemirrorFill(`> [color=#b51f08] HedgeDoc`) + cy.setCodemirrorContent(`> [color=#b51f08] HedgeDoc`) cy.getMarkdownBody() .find('.quote-extra') diff --git a/cypress/integration/renderer-mode.spec.ts b/cypress/integration/renderer-mode.spec.ts new file mode 100644 index 000000000..47d08012d --- /dev/null +++ b/cypress/integration/renderer-mode.spec.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +describe('Renderer mode', () => { + beforeEach(() => { + cy.visitTestEditor() + }) + + it("should be 'document' without type specified", () => { + cy.getMarkdownBody().should('exist') + cy.getReveal().should('not.exist') + }) + + it("should be 'reveal.js' with type 'slide'", () => { + cy.setCodemirrorContent('---\ntype: slide\n---\n') + cy.getMarkdownBody().should('not.exist') + cy.getReveal().should('exist') + }) + + it("should be 'document' with invalid type", () => { + cy.setCodemirrorContent('---\ntype: EinDokument\n---\n') + cy.getMarkdownBody().should('exist') + cy.getReveal().should('not.exist') + }) + + it("should change from 'reveal.js' to 'document' if changed from 'slide' to something else", () => { + cy.setCodemirrorContent('---\ntype: slide\n---\n') + cy.getMarkdownBody().should('not.exist') + cy.getReveal().should('exist') + cy.setCodemirrorContent('') + cy.getMarkdownBody().should('exist') + cy.getReveal().should('not.exist') + }) +}) diff --git a/cypress/integration/shortcodes.spec.ts b/cypress/integration/shortcodes.spec.ts index 719c3ab9b..79cc514bb 100644 --- a/cypress/integration/shortcodes.spec.ts +++ b/cypress/integration/shortcodes.spec.ts @@ -11,7 +11,7 @@ describe('Short code gets replaced or rendered: ', () => { describe('pdf', () => { it('renders a plain link', () => { - cy.codemirrorFill(`{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}`) + cy.setCodemirrorContent(`{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}`) cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf') @@ -20,7 +20,7 @@ describe('Short code gets replaced or rendered: ', () => { describe('slideshare', () => { it('renders a plain link', () => { - cy.codemirrorFill(`{%slideshare example/123456789 %}`) + cy.setCodemirrorContent(`{%slideshare example/123456789 %}`) cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'https://www.slideshare.net/example/123456789') @@ -29,7 +29,7 @@ describe('Short code gets replaced or rendered: ', () => { describe('speakerdeck', () => { it('renders a plain link', () => { - cy.codemirrorFill(`{%speakerdeck example/123456789 %}`) + cy.setCodemirrorContent(`{%speakerdeck example/123456789 %}`) cy.getMarkdownBody() .find('a') .should('have.attr', 'href', 'https://speakerdeck.com/example/123456789') @@ -38,7 +38,7 @@ describe('Short code gets replaced or rendered: ', () => { describe('youtube', () => { it('renders one-click-embedding', () => { - cy.codemirrorFill(`{%youtube YE7VzlLtp-4 %}`) + cy.setCodemirrorContent(`{%youtube YE7VzlLtp-4 %}`) cy.getMarkdownBody() .find('.one-click-embedding.embed-responsive-item') }) diff --git a/cypress/integration/taskLists.spec.ts b/cypress/integration/taskLists.spec.ts index 8f6b2a922..94e01ecfd 100644 --- a/cypress/integration/taskLists.spec.ts +++ b/cypress/integration/taskLists.spec.ts @@ -16,21 +16,21 @@ describe('Task lists ', () => { describe('render with checkboxes ', () => { it('when unchecked', () => { - cy.codemirrorFill(TEST_STRING_UNCHECKED) + cy.setCodemirrorContent(TEST_STRING_UNCHECKED) cy.getMarkdownBody() .find('input[type=checkbox]') .should('have.length', 6) }) it('when checked lowercase', () => { - cy.codemirrorFill(TEST_STRING_CHECKED_LOWER) + cy.setCodemirrorContent(TEST_STRING_CHECKED_LOWER) cy.getMarkdownBody() .find('input[type=checkbox]') .should('have.length', 6) }) it('when checked uppercase', () => { - cy.codemirrorFill(TEST_STRING_CHECKED_UPPER) + cy.setCodemirrorContent(TEST_STRING_CHECKED_UPPER) cy.getMarkdownBody() .find('input[type=checkbox]') .should('have.length', 6) @@ -38,7 +38,7 @@ describe('Task lists ', () => { }) it('do not render as checkboxes when invalid', () => { - cy.codemirrorFill(TEST_STRING_INVALID) + cy.setCodemirrorContent(TEST_STRING_INVALID) cy.getMarkdownBody() .find('input[type=checkbox]') .should('have.length', 0) @@ -46,7 +46,7 @@ describe('Task lists ', () => { describe('are clickable and change the markdown source ', () => { it('from unchecked to checked', () => { - cy.codemirrorFill(TEST_STRING_UNCHECKED) + cy.setCodemirrorContent(TEST_STRING_UNCHECKED) cy.getMarkdownBody() .find('input[type=checkbox]') .each(box => { @@ -59,7 +59,7 @@ describe('Task lists ', () => { }) it('from checked (lowercase) to unchecked', () => { - cy.codemirrorFill(TEST_STRING_CHECKED_LOWER) + cy.setCodemirrorContent(TEST_STRING_CHECKED_LOWER) cy.getMarkdownBody() .find('input[type=checkbox]') .each(box => { @@ -72,7 +72,7 @@ describe('Task lists ', () => { }) it('from checked (uppercase) to unchecked', () => { - cy.codemirrorFill(TEST_STRING_CHECKED_UPPER) + cy.setCodemirrorContent(TEST_STRING_CHECKED_UPPER) cy.getMarkdownBody() .find('input[type=checkbox]') .each(box => { diff --git a/cypress/integration/toolbar.spec.ts b/cypress/integration/toolbar.spec.ts index 3053c9c0f..c3f35a28a 100644 --- a/cypress/integration/toolbar.spec.ts +++ b/cypress/integration/toolbar.spec.ts @@ -19,7 +19,7 @@ describe('Toolbar Buttons', () => { describe('for single line text', () => { beforeEach(() => { - cy.codemirrorFill(testText) + cy.setCodemirrorContent(testText) cy.get('.CodeMirror-line > span') .should('exist') .should('have.text', testText) @@ -182,7 +182,7 @@ describe('Toolbar Buttons', () => { describe('for single line link with selection', () => { beforeEach(() => { - cy.codemirrorFill(testLink) + cy.setCodemirrorContent(testLink) cy.get('.CodeMirror-line > span') .should('exist') .should('have.text', testLink) diff --git a/cypress/integration/word-count.spec.ts b/cypress/integration/word-count.spec.ts index 912e527e9..ce3c11949 100644 --- a/cypress/integration/word-count.spec.ts +++ b/cypress/integration/word-count.spec.ts @@ -10,7 +10,7 @@ describe('Test word count with', () => { }) it('empty note', () => { - cy.codemirrorFill('') + cy.setCodemirrorContent('') cy.wait(500) cy.get('[data-cy="sidebar-btn-document-info"]').click() cy.get('[data-cy="document-info-modal"]').should('be.visible') @@ -18,7 +18,7 @@ describe('Test word count with', () => { }) it('simple words', () => { - cy.codemirrorFill('five words should be enough') + cy.setCodemirrorContent('five words should be enough') cy.wait(500) cy.get('[data-cy="sidebar-btn-document-info"]').click() cy.get('[data-cy="document-info-modal"]').should('be.visible') @@ -26,7 +26,7 @@ describe('Test word count with', () => { }) it('excluded codeblocks', () => { - cy.codemirrorFill('```\nthis is should be ignored\n```\n\ntwo `words`') + cy.setCodemirrorContent('```\nthis is should be ignored\n```\n\ntwo `words`') cy.wait(500) cy.get('[data-cy="sidebar-btn-document-info"]').click() cy.get('[data-cy="document-info-modal"]').should('be.visible') @@ -34,7 +34,7 @@ describe('Test word count with', () => { }) it('excluded images', () => { - cy.codemirrorFill('![ignored alt text](https://dummyimage.com/48) not ignored text') + cy.setCodemirrorContent('![ignored alt text](https://dummyimage.com/48) not ignored text') cy.wait(500) cy.get('[data-cy="sidebar-btn-document-info"]').click() cy.get('[data-cy="document-info-modal"]').should('be.visible') diff --git a/cypress/integration/yamlArrayDeprecationMessage.spec.ts b/cypress/integration/yamlArrayDeprecationMessage.spec.ts index 681a711ef..01c46fe25 100644 --- a/cypress/integration/yamlArrayDeprecationMessage.spec.ts +++ b/cypress/integration/yamlArrayDeprecationMessage.spec.ts @@ -10,22 +10,22 @@ describe('YAML Array for deprecated syntax of document tags in frontmatter', () }) it('is shown when using old syntax', () => { - cy.codemirrorFill('---\ntags: a, b, c\n---') - cy.getMarkdownRenderer() + cy.setCodemirrorContent('---\ntags: a, b, c\n---') + cy.getIframeBody() .find('[data-cy="yamlArrayDeprecationAlert"]') .should('be.visible') }) it('isn\'t shown when using inline yaml-array', () => { - cy.codemirrorFill('---\ntags: [\'a\', \'b\', \'c\']\n---') - cy.getMarkdownRenderer() + cy.setCodemirrorContent('---\ntags: [\'a\', \'b\', \'c\']\n---') + cy.getIframeBody() .find('[data-cy="yamlArrayDeprecationAlert"]') .should('not.exist') }) it('isn\'t shown when using multi line yaml-array', () => { - cy.codemirrorFill('---\ntags:\n - a\n - b\n - c\n---') - cy.getMarkdownRenderer() + cy.setCodemirrorContent('---\ntags:\n - a\n - b\n - c\n---') + cy.getIframeBody() .find('[data-cy="yamlArrayDeprecationAlert"]') .should('not.exist') }) diff --git a/cypress/support/checkLinks.ts b/cypress/support/checkLinks.ts index 5cbe666ea..5ceac01b8 100644 --- a/cypress/support/checkLinks.ts +++ b/cypress/support/checkLinks.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// eslint-disable-next-line @typescript-eslint/no-namespace declare namespace Cypress { interface Chainable { /** diff --git a/cypress/support/fill.ts b/cypress/support/fill.ts index 62b103f6c..ad6e8e861 100644 --- a/cypress/support/fill.ts +++ b/cypress/support/fill.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// eslint-disable-next-line @typescript-eslint/no-namespace declare namespace Cypress { interface Chainable { /** @@ -13,7 +12,7 @@ declare namespace Cypress { */ fill(value: string): Chainable - codemirrorFill(value: string): Chainable + setCodemirrorContent(value: string): Chainable } } @@ -25,12 +24,14 @@ Cypress.Commands.add('fill', { .trigger('change', { force: true }) }) -Cypress.Commands.add('codemirrorFill', (content: string) => { +Cypress.Commands.add('setCodemirrorContent', (content: string) => { const line = content.split('\n') .find(value => value !== '') cy.get('.CodeMirror') .click() .get('textarea') + .type('{ctrl}a') + .type('{backspace}') .fill(content) if (line) { cy.get('.CodeMirror') diff --git a/cypress/support/getMarkdownRenderer.ts b/cypress/support/get-iframe-content.ts similarity index 66% rename from cypress/support/getMarkdownRenderer.ts rename to cypress/support/get-iframe-content.ts index 8d4e9e4ea..2c3f3dbcd 100644 --- a/cypress/support/getMarkdownRenderer.ts +++ b/cypress/support/get-iframe-content.ts @@ -6,13 +6,13 @@ declare namespace Cypress { interface Chainable { - getMarkdownRenderer(): Chainable - + getIframeBody(): Chainable + getReveal(): Chainable getMarkdownBody(): Chainable } } -Cypress.Commands.add('getMarkdownRenderer', () => { +Cypress.Commands.add('getIframeBody', () => { return cy .get(`iframe[data-cy="documentIframe"][data-content-ready="true"]`) .should('be.visible') @@ -23,6 +23,10 @@ Cypress.Commands.add('getMarkdownRenderer', () => { .then(cy.wrap.bind(cy)) }) -Cypress.Commands.add('getMarkdownBody', () => { - return cy.getMarkdownRenderer().find('.markdown-body') +Cypress.Commands.add('getReveal', () => { + return cy.getIframeBody().find('.reveal') +}) + +Cypress.Commands.add('getMarkdownBody', () => { + return cy.getIframeBody().find('.markdown-body') }) diff --git a/cypress/support/index.ts b/cypress/support/index.ts index f73b7cc27..5fe7d01a1 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -24,6 +24,6 @@ import 'cypress-file-upload' import './checkLinks' import './config' import './fill' -import './getMarkdownRenderer' +import './get-iframe-content' import './login' import './visit-test-editor' diff --git a/cypress/support/login.ts b/cypress/support/login.ts index b21c4d3d9..33795287e 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// eslint-disable-next-line @typescript-eslint/no-namespace declare namespace Cypress { interface Chainable { /** diff --git a/package.json b/package.json index c6ac14132..e8ccebb67 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "react-scripts": "4.0.3", "react-use": "17.3.1", "redux": "4.1.1", + "reveal.js": "4.1.3", "sanitize-filename": "1.6.3", "ts-mockery": "1.2.0", "twemoji-colr-font": "0.0.4", diff --git a/public/mock-backend/api/private/me/history b/public/mock-backend/api/private/me/history index 93f9acf82..1a7327dae 100644 --- a/public/mock-backend/api/private/me/history +++ b/public/mock-backend/api/private/me/history @@ -9,6 +9,17 @@ "Community Call" ] }, + { + "identifier": "slide-example", + "title": "Slide example", + "lastVisited": "2020-05-30T15:20:36.088Z", + "pinStatus": true, + "tags": [ + "features", + "cool", + "updated" + ] + }, { "identifier": "features", "title": "Features", diff --git a/public/mock-backend/api/private/notes/slide-example-get b/public/mock-backend/api/private/notes/slide-example-get new file mode 100644 index 000000000..c25346665 --- /dev/null +++ b/public/mock-backend/api/private/notes/slide-example-get @@ -0,0 +1,18 @@ +{ + "content": "---\ntype: slide\nslideOptions:\n transition: slide\n---\n\n# Slide example\n\nThis feature still in beta, may have some issues.\n\nFor details please visit:\n\n\nYou can use `URL query` or `slideOptions` of the YAML metadata to customize your slides.\n\n---\n\n## First slide\n\n`---`\n\nIs the divider of slides\n\n----\n\n### First branch of first the slide\n\n`----`\n\nIs the divider of branches\n\nUse the *Space* key to navigate through all slides.\n\n----\n\n### Second branch of first the slide\n\nNested slides are useful for adding additional detail underneath a high-level horizontal slide.\n\n---\n\n## Point of View\n\nPress **ESC** to enter the slide overview.\n\n---\n\n## Touch Optimized\n\nPresentations look great on touch devices, like mobile phones and tablets. Simply swipe through your slides.\n\n---\n\n## Fragments\n\n``\n\nIs the fragment syntax\n\nHit the next arrow...\n\n... to step through ...\n\n... a fragmented slide.\n\nNote:\n This slide has fragments which are also stepped through in the notes window.\n\n---\n\n## Fragment Styles\n\nThere are different types of fragments, like:\n\ngrow\n\nshrink\n\nfade-out\n\nfade-up (also down, left and right!)\n\ncurrent-visible\n\nHighlight red blue green\n\n---\n\n\n\n## Transition Styles\nDifferent background transitions are available via the transition option. This one's called \"zoom\".\n\n``\n\nIs the transition syntax\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\n---\n\n\n\n``\n\nAlso, you can set different in/out transition\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\npostfix with `-in` or `-out`\n\n---\n\n\n\n``\n\nCustom the transition speed!\n\nYou can use:\n\ndefault/fast/slow\n\n---\n\n## Themes\n\nreveal.js comes with a few themes built in:\n\nBlack (default) - White - League - Sky - Beige - Simple\n\nSerif - Blood - Night - Moon - Solarized\n\nIt can be set in YAML slideOptions\n\n---\n\n\n\n``\n\nIs the background syntax\n\n---\n\n\n\n
\n\n## Image Backgrounds\n\n``\n\n
\n\n----\n\n\n\n
\n\n## Tiled Backgrounds\n\n``\n\n
\n\n----\n\n\n\n
\n\n## Video Backgrounds\n\n``\n\n
\n\n----\n\n\n\n## ... and GIFs!\n\n---\n\n## Pretty Code\n\n``` javascript\nfunction linkify( selector ) {\n if( supports3DTransforms ) {\n\n const nodes = document.querySelectorAll( selector );\n\n for( const i = 0, len = nodes.length; i < len; i++ ) {\n var node = nodes[i];\n\n if( !node.className ) {\n node.className += ' roll';\n }\n }\n }\n}\n```\nCode syntax highlighting courtesy of [highlight.js](http://softwaremaniacs.org/soft/highlight/en/description/).\n\n---\n\n## Marvelous List\n\n- No order here\n- Or here\n- Or here\n- Or here\n\n---\n\n## Fantastic Ordered List\n\n1. One is smaller than...\n2. Two is smaller than...\n3. Three!\n\n---\n\n## Tabular Tables\n\n| Item | Value | Quantity |\n| ---- | ----- | -------- |\n| Apples | $1 | 7 |\n| Lemonade | $2 | 18 |\n| Bread | $3 | 2 |\n\n---\n\n## Clever Quotes\n\n> “For years there has been a theory that millions of monkeys typing at random on millions of typewriters would reproduce the entire works of Shakespeare. The Internet has proven this theory to be untrue.”\n\n---\n\n## Intergalactic Interconnections\n\nYou can link between slides internally, [like this](#/1/3).\n\n---\n\n## Speaker\n\nThere's a [speaker view](https://github.com/hakimel/reveal.js#speaker-notes). It includes a timer, preview of the upcoming slide as well as your speaker notes.\n\nPress the *S* key to try it out.\n\nNote:\n Oh hey, these are some notes. They'll be hidden in your presentation, but you can see them if you open the speaker notes window (hit `s` on your keyboard).\n\n---\n\n## Take a Moment\n\nPress `B` or `.` on your keyboard to pause the presentation. This is helpful when you're on stage and want to take distracting slides off the screen.\n\n---\n\n## Print your Slides\n\nDown below you can find a print icon.\n\nAfter you click on it, use the print function of your browser (either CTRL+P or cmd+P) to print the slides as PDF. [See official reveal.js instructions for details](https://github.com/hakimel/reveal.js#instructions-1)\n\n---\n\n# The End\n\n", + "metadata": { + "id": "SLIDE2", + "alias": "slide-example", + "version": 2, + "viewCount": 0, + "updateTime": "2021-04-30T18:38:23.000Z", + "updateUser": { + "userName": "test", + "displayName": "Testy", + "photo": "", + "email": "" + }, + "createTime": "2021-04-30T18:38:14.000Z", + "editedBy": [] + } +} diff --git a/src/components/common/note-frontmatter/extract-frontmatter.test.ts b/src/components/common/note-frontmatter/extract-frontmatter.test.ts index bcf4ab43a..2adc9e3db 100644 --- a/src/components/common/note-frontmatter/extract-frontmatter.test.ts +++ b/src/components/common/note-frontmatter/extract-frontmatter.test.ts @@ -8,87 +8,87 @@ import { extractFrontmatter } from './extract-frontmatter' import { PresentFrontmatterExtractionResult } from './types' describe('frontmatter extraction', () => { - describe('frontmatterPresent property', () => { + describe('isPresent 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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).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) + expect(extraction.isPresent).toBe(true) }) }) - describe('frontmatterLines property', () => { + describe('lineOffset 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) + expect(extraction.lineOffset).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) + expect(extraction.lineOffset).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) + expect(extraction.lineOffset).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) + expect(extraction.lineOffset).toEqual(5) }) }) - describe('rawFrontmatterText property', () => { + describe('rawText 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') + expect(extraction.rawText).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') + expect(extraction.rawText).toEqual('multi\nline') }) }) }) diff --git a/src/components/common/note-frontmatter/extract-frontmatter.ts b/src/components/common/note-frontmatter/extract-frontmatter.ts index 940e1e99d..756a3f0ba 100644 --- a/src/components/common/note-frontmatter/extract-frontmatter.ts +++ b/src/components/common/note-frontmatter/extract-frontmatter.ts @@ -13,7 +13,7 @@ const FRONTMATTER_END_REGEX = /^(?:-{3,}|\.{3,})$/ * 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. + * @return { isPresent } 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. @@ -22,19 +22,19 @@ export const extractFrontmatter = (content: string): FrontmatterExtractionResult const lines = content.split('\n') if (lines.length < 2 || !FRONTMATTER_BEGIN_REGEX.test(lines[0])) { return { - frontmatterPresent: false + isPresent: 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 + isPresent: true, + rawText: lines.slice(1, i).join('\n'), + lineOffset: i + 1 } } } return { - frontmatterPresent: false + isPresent: false } } diff --git a/src/components/common/note-frontmatter/note-frontmatter.test.ts b/src/components/common/note-frontmatter/note-frontmatter.test.ts index efd0b95e8..2073889b9 100644 --- a/src/components/common/note-frontmatter/note-frontmatter.test.ts +++ b/src/components/common/note-frontmatter/note-frontmatter.test.ts @@ -4,27 +4,27 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { NoteFrontmatter } from './note-frontmatter' +import { createNoteFrontmatterFromYaml } from './note-frontmatter' describe('yaml frontmatter', () => { it('should parse "title"', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml('title: test') + const noteFrontmatter = createNoteFrontmatterFromYaml('title: test') expect(noteFrontmatter.title).toEqual('test') }) it('should parse "robots"', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml('robots: index, follow') + const noteFrontmatter = createNoteFrontmatterFromYaml('robots: index, follow') expect(noteFrontmatter.robots).toEqual('index, follow') }) it('should parse the deprecated tags syntax', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml('tags: test123, abc') + const noteFrontmatter = createNoteFrontmatterFromYaml('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: + const noteFrontmatter = createNoteFrontmatterFromYaml(`tags: - test123 - abc `) @@ -33,30 +33,30 @@ describe('yaml frontmatter', () => { }) it('should parse the tag inline-list syntax', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml("tags: ['test123', 'abc']") + const noteFrontmatter = createNoteFrontmatterFromYaml("tags: ['test123', 'abc']") expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) expect(noteFrontmatter.deprecatedTagsSyntax).toEqual(false) }) it('should parse "breaks"', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml('breaks: false') + const noteFrontmatter = createNoteFrontmatterFromYaml('breaks: false') expect(noteFrontmatter.breaks).toEqual(false) }) it('should parse an empty opengraph object', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml('opengraph:') + const noteFrontmatter = createNoteFrontmatterFromYaml('opengraph:') expect(noteFrontmatter.opengraph).toEqual(new Map()) }) it('should parse an opengraph title', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml(`opengraph: + const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph: title: Testtitle `) expect(noteFrontmatter.opengraph.get('title')).toEqual('Testtitle') }) it('should parse multiple opengraph values', () => { - const noteFrontmatter = NoteFrontmatter.createFromYaml(`opengraph: + const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph: title: Testtitle image: https://dummyimage.com/48.png image:type: image/png diff --git a/src/components/common/note-frontmatter/note-frontmatter.ts b/src/components/common/note-frontmatter/note-frontmatter.ts index 1c69bf01a..a33c98100 100644 --- a/src/components/common/note-frontmatter/note-frontmatter.ts +++ b/src/components/common/note-frontmatter/note-frontmatter.ts @@ -6,12 +6,13 @@ // import { RevealOptions } from 'reveal.js' import { load } from 'js-yaml' -import { ISO6391, NoteTextDirection, NoteType, RawNoteFrontmatter } from './types' +import { ISO6391, NoteTextDirection, NoteType, RawNoteFrontmatter, SlideOptions } from './types' +import { initialSlideOptions } from '../../../redux/note-details/initial-state' /** * Class that represents the parsed frontmatter metadata of a note. */ -export class NoteFrontmatter { +export interface NoteFrontmatter { title: string description: string tags: string[] @@ -24,47 +25,98 @@ export class NoteFrontmatter { disqus: string type: NoteType opengraph: Map + slideOptions: SlideOptions +} - /** - * 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 the given raw metadata properties. + * @param rawData A {@link RawNoteFrontmatter} object containing the properties of the parsed yaml frontmatter. + */ +export const parseRawNoteFrontmatter = (rawData: RawNoteFrontmatter): NoteFrontmatter => { + let tags: string[] + let deprecatedTagsSyntax: boolean + if (typeof rawData?.tags === 'string') { + tags = rawData?.tags?.split(',').map((entry) => entry.trim()) ?? [] + deprecatedTagsSyntax = true + } else if (typeof rawData?.tags === 'object') { + tags = rawData?.tags?.filter((tag) => tag !== null) ?? [] + deprecatedTagsSyntax = false + } else { + tags = [] + deprecatedTagsSyntax = false } - /** - * 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) + return { + title: rawData.title ?? '', + description: rawData.description ?? '', + robots: rawData.robots ?? '', + breaks: rawData.breaks ?? true, + GA: rawData.GA ?? '', + disqus: rawData.disqus ?? '', + lang: (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? 'en', + type: + (rawData.type ? Object.values(NoteType).find((type) => type === rawData.type) : undefined) ?? NoteType.DOCUMENT, + dir: + (rawData.dir ? Object.values(NoteTextDirection).find((dir) => dir === rawData.dir) : undefined) ?? + NoteTextDirection.LTR, + opengraph: rawData?.opengraph + ? new Map(Object.entries(rawData.opengraph)) + : new Map(), + + slideOptions: parseSlideOptions(rawData), + tags, + deprecatedTagsSyntax } } + +/** + * Parses the {@link SlideOptions} from the {@link RawNoteFrontmatter}. + * + * @param rawData The raw note frontmatter data. + * @return the parsed slide options + */ +const parseSlideOptions = (rawData: RawNoteFrontmatter): SlideOptions => { + const rawSlideOptions = rawData?.slideOptions + return { + autoSlide: parseNumber(rawSlideOptions?.autoSlide) ?? initialSlideOptions.autoSlide, + transition: rawSlideOptions?.transition ?? initialSlideOptions.transition, + backgroundTransition: rawSlideOptions?.backgroundTransition ?? initialSlideOptions.backgroundTransition, + autoSlideStoppable: parseBoolean(rawSlideOptions?.autoSlideStoppable) ?? initialSlideOptions.autoSlideStoppable, + slideNumber: parseBoolean(rawSlideOptions?.slideNumber) ?? initialSlideOptions.slideNumber + } +} + +/** + * Parses an unknown variable into a boolean. + * + * @param rawData The raw data + * @return The parsed boolean or undefined if it's not possible to parse the data. + */ +const parseBoolean = (rawData: unknown | undefined): boolean | undefined => { + return rawData === undefined ? undefined : rawData === true +} + +/** + * Parses an unknown variable into a number. + * + * @param rawData The raw data + * @return The parsed number or undefined if it's not possible to parse the data. + */ +const parseNumber = (rawData: unknown | undefined): number | undefined => { + if (rawData === undefined) { + return undefined + } + const numValue = Number(rawData) + return isNaN(numValue) ? undefined : numValue +} + +/** + * 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. + */ +export const createNoteFrontmatterFromYaml = (rawYaml: string): NoteFrontmatter => { + const rawNoteFrontmatter = load(rawYaml) as RawNoteFrontmatter + return parseRawNoteFrontmatter(rawNoteFrontmatter) +} diff --git a/src/components/common/note-frontmatter/types.ts b/src/components/common/note-frontmatter/types.ts index 4b1ebd699..1bb0fa3ae 100644 --- a/src/components/common/note-frontmatter/types.ts +++ b/src/components/common/note-frontmatter/types.ts @@ -4,22 +4,33 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { RevealOptions } from 'reveal.js' + export type FrontmatterExtractionResult = PresentFrontmatterExtractionResult | NonPresentFrontmatterExtractionResult +export type WantedRevealOptions = + | 'autoSlide' + | 'autoSlideStoppable' + | 'transition' + | 'backgroundTransition' + | 'slideNumber' +export type SlideOptions = Required> + export interface RendererFrontmatterInfo { - offsetLines: number + lineOffset: number frontmatterInvalid: boolean deprecatedSyntax: boolean + slideOptions: SlideOptions } export interface PresentFrontmatterExtractionResult { - frontmatterPresent: true - rawFrontmatterText: string - frontmatterLines: number + isPresent: true + rawText: string + lineOffset: number } interface NonPresentFrontmatterExtractionResult { - frontmatterPresent: false + isPresent: false } export interface RawNoteFrontmatter { @@ -33,7 +44,7 @@ export interface RawNoteFrontmatter { GA: string | undefined disqus: string | undefined type: string | undefined - slideOptions: unknown + slideOptions: { [key: string]: string } | null opengraph: { [key: string]: string } | null } 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 dcd5b09e7..0e34ade42 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 @@ -50,21 +50,7 @@ export const DocumentReadOnlyPage: React.FC = () => { - - + diff --git a/src/components/editor-page/app-bar/help-button/cheatsheet-line.tsx b/src/components/editor-page/app-bar/help-button/cheatsheet-line.tsx index 1a4522a71..e1735f709 100644 --- a/src/components/editor-page/app-bar/help-button/cheatsheet-line.tsx +++ b/src/components/editor-page/app-bar/help-button/cheatsheet-line.tsx @@ -15,7 +15,7 @@ export interface CheatsheetLineProps { const HighlightedCode = React.lazy( () => import('../../../markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code') ) -const BasicMarkdownRenderer = React.lazy(() => import('../../../markdown-renderer/basic-markdown-renderer')) +const DocumentMarkdownRenderer = React.lazy(() => import('../../../markdown-renderer/document-markdown-renderer')) export const CheatsheetLine: React.FC = ({ code, onTaskCheckedChange }) => { const checkboxClick = useCallback( @@ -36,7 +36,11 @@ export const CheatsheetLine: React.FC = ({ code, onTaskChec }> - + diff --git a/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx b/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx index dbcd78cbe..2c0e70e5a 100644 --- a/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx +++ b/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx @@ -21,5 +21,5 @@ export const EditorDocumentRenderer: React.FC = (pr useSendFrontmatterInfoFromReduxToRenderer() - return + return } diff --git a/src/components/editor-page/editor-page.tsx b/src/components/editor-page/editor-page.tsx index 7f26081c1..50951cf53 100644 --- a/src/components/editor-page/editor-page.tsx +++ b/src/components/editor-page/editor-page.tsx @@ -29,6 +29,7 @@ import { useApplicationState } from '../../hooks/common/use-application-state' import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer' import { EditorToRendererCommunicatorContextProvider } from './render-context/editor-to-renderer-communicator-context-provider' import { Logger } from '../../utils/logger' +import { NoteType } from '../common/note-frontmatter/types' export interface EditorPagePathParams { id: string @@ -107,6 +108,7 @@ export const EditorPage: React.FC = () => { ), [onEditorScroll, scrollState.editorScrollState, setEditorToScrollSource] ) + const noteType: NoteType = useApplicationState((state) => state.noteDetails.frontmatter.type) const rightPane = useMemo( () => ( @@ -117,10 +119,10 @@ export const EditorPage: React.FC = () => { onTaskCheckedChange={setCheckboxInMarkdownContent} onScroll={onMarkdownRendererScroll} scrollState={scrollState.rendererScrollState} - rendererType={RendererType.DOCUMENT} + rendererType={noteType === NoteType.SLIDE ? RendererType.SLIDESHOW : RendererType.DOCUMENT} /> ), - [onMarkdownRendererScroll, scrollState.rendererScrollState, setRendererToScrollSource] + [noteType, onMarkdownRendererScroll, scrollState.rendererScrollState, setRendererToScrollSource] ) return ( diff --git a/src/components/editor-page/renderer-pane/hooks/use-effect-on-render-type-change.ts b/src/components/editor-page/renderer-pane/hooks/use-effect-on-render-type-change.ts new file mode 100644 index 000000000..7531be8ac --- /dev/null +++ b/src/components/editor-page/renderer-pane/hooks/use-effect-on-render-type-change.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useRef } from 'react' +import { RendererType } from '../../../render-page/window-post-message-communicator/rendering-message' + +/** + * Execute the given reload callback if the given render type changes. + * + * @param rendererType The render type to watch + * @param effectCallback The callback that should be executed if the render type changes. + */ +export const useEffectOnRenderTypeChange = (rendererType: RendererType, effectCallback: () => void): void => { + const lastRendererType = useRef(rendererType) + + useEffect(() => { + if (lastRendererType.current === rendererType) { + return + } + effectCallback() + lastRendererType.current = rendererType + }, [effectCallback, rendererType]) +} diff --git a/src/components/editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer.ts b/src/components/editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer.ts index 1d24fc835..152e4d110 100644 --- a/src/components/editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer.ts +++ b/src/components/editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer.ts @@ -5,9 +5,11 @@ */ import { useSendToRenderer } from '../../../render-page/window-post-message-communicator/hooks/use-send-to-renderer' -import { useMemo } from 'react' +import { useMemo, useRef } from 'react' import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message' import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { RendererFrontmatterInfo } from '../../../common/note-frontmatter/types' +import equal from 'fast-deep-equal' /** * Extracts the {@link RendererFrontmatterInfo frontmatter data} @@ -15,14 +17,24 @@ import { useApplicationState } from '../../../../hooks/common/use-application-st */ export const useSendFrontmatterInfoFromReduxToRenderer = (): void => { const frontmatterInfo = useApplicationState((state) => state.noteDetails.frontmatterRendererInfo) + const lastFrontmatter = useRef(undefined) + + const cachedFrontmatterInfo = useMemo(() => { + if (lastFrontmatter.current !== undefined && equal(lastFrontmatter.current, frontmatterInfo)) { + return lastFrontmatter.current + } else { + lastFrontmatter.current = frontmatterInfo + return frontmatterInfo + } + }, [frontmatterInfo]) return useSendToRenderer( useMemo( () => ({ type: CommunicationMessageType.SET_FRONTMATTER_INFO, - frontmatterInfo + frontmatterInfo: cachedFrontmatterInfo }), - [frontmatterInfo] + [cachedFrontmatterInfo] ) ) } diff --git a/src/components/editor-page/renderer-pane/render-iframe.tsx b/src/components/editor-page/renderer-pane/render-iframe.tsx index 509a41ffd..4ff612168 100644 --- a/src/components/editor-page/renderer-pane/render-iframe.tsx +++ b/src/components/editor-page/renderer-pane/render-iframe.tsx @@ -25,6 +25,7 @@ import { useSendMarkdownToRenderer } from './hooks/use-send-markdown-to-renderer import { useSendScrollState } from './hooks/use-send-scroll-state' import { useApplicationState } from '../../../hooks/common/use-application-state' import { Logger } from '../../../utils/logger' +import { useEffectOnRenderTypeChange } from './hooks/use-effect-on-render-type-change' export interface RenderIframeProps extends RendererProps { rendererType: RendererType @@ -50,9 +51,8 @@ export const RenderIframe: React.FC = ({ const iframeCommunicator = useEditorToRendererCommunicator() const resetRendererReady = useCallback(() => { log.debug('Reset render status') - iframeCommunicator.unsetMessageTarget() setRendererStatus(false) - }, [iframeCommunicator]) + }, []) const rendererReady = useIsRendererReady() const onIframeLoad = useForceRenderPageUrlOnIframeLoadCallback(frameReference, rendererOrigin, resetRendererReady) const [frameHeight, setFrameHeight] = useState(0) @@ -65,6 +65,12 @@ export const RenderIframe: React.FC = ({ [iframeCommunicator] ) + useEffect(() => { + if (!rendererReady) { + iframeCommunicator.unsetMessageTarget() + } + }, [iframeCommunicator, rendererReady]) + useEditorReceiveHandler( CommunicationMessageType.ON_FIRST_HEADING_CHANGE, useCallback( @@ -123,6 +129,7 @@ export const RenderIframe: React.FC = ({ }, [iframeCommunicator, rendererOrigin, rendererType]) ) + useEffectOnRenderTypeChange(rendererType, onIframeLoad) useSendScrollState(scrollState) useSendDarkModeStatusToRenderer(forcedDarkMode) useSendMarkdownToRenderer(markdownContent) @@ -136,7 +143,9 @@ export const RenderIframe: React.FC = ({ onLoad={onIframeLoad} title='render' {...(isTestMode() ? {} : { sandbox: 'allow-downloads allow-same-origin allow-scripts allow-popups' })} + allowFullScreen={true} ref={frameReference} + referrerPolicy={'no-referrer'} className={`border-0 ${frameClasses ?? ''}`} data-content-ready={rendererReady} /> diff --git a/src/components/markdown-renderer/common-markdown-renderer-props.ts b/src/components/markdown-renderer/common-markdown-renderer-props.ts new file mode 100644 index 000000000..2bc08742c --- /dev/null +++ b/src/components/markdown-renderer/common-markdown-renderer-props.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { TocAst } from 'markdown-it-toc-done-right' +import { ImageClickHandler } from './replace-components/image/image-replacer' +import { Ref } from 'react' + +export interface CommonMarkdownRendererProps { + onFirstHeadingChange?: (firstHeading: string | undefined) => void + onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void + onTocChange?: (ast?: TocAst) => void + baseUrl?: string + onImageClick?: ImageClickHandler + outerContainerRef?: Ref + useAlternativeBreaks?: boolean + lineOffset?: number + className?: string + content: string +} diff --git a/src/components/markdown-renderer/basic-markdown-renderer.tsx b/src/components/markdown-renderer/document-markdown-renderer.tsx similarity index 65% rename from src/components/markdown-renderer/basic-markdown-renderer.tsx rename to src/components/markdown-renderer/document-markdown-renderer.tsx index 2f9971dc4..2eae91826 100644 --- a/src/components/markdown-renderer/basic-markdown-renderer.tsx +++ b/src/components/markdown-renderer/document-markdown-renderer.tsx @@ -4,12 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Ref, useCallback, useMemo, useRef } from 'react' +import React, { 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' -import { ComponentReplacer } from './replace-components/ComponentReplacer' -import { AdditionalMarkdownRendererProps, LineMarkerPosition } from './types' +import { LineMarkerPosition } from './types' import { useComponentReplacers } from './hooks/use-component-replacers' import { useTranslation } from 'react-i18next' import { LineMarkers } from './replace-components/linemarker/line-number-marker' @@ -18,28 +17,16 @@ import { useExtractFirstHeadline } from './hooks/use-extract-first-headline' import { TocAst } from 'markdown-it-toc-done-right' import { useOnRefChange } from './hooks/use-on-ref-change' import { BasicMarkdownItConfigurator } from './markdown-it-configurator/basic-markdown-it-configurator' -import { ImageClickHandler } from './replace-components/image/image-replacer' import { useTrimmedContent } from './hooks/use-trimmed-content' +import { CommonMarkdownRendererProps } from './common-markdown-renderer-props' -export interface BasicMarkdownRendererProps { - additionalReplacers?: () => ComponentReplacer[] - onBeforeRendering?: () => void - onAfterRendering?: () => void - onFirstHeadingChange?: (firstHeading: string | undefined) => void +export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererProps { onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => 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 = ({ +export const DocumentMarkdownRenderer: React.FC = ({ className, content, - additionalReplacers, onFirstHeadingChange, onLineMarkerPositionChanged, onTaskCheckedChange, @@ -48,7 +35,7 @@ export const BasicMarkdownRenderer: React.FC { const markdownBodyRef = useRef(null) const currentLineMarkers = useRef() @@ -64,17 +51,12 @@ export const BasicMarkdownRenderer: React.FC (currentLineMarkers.current = lineMarkers), useAlternativeBreaks, - offsetLines: frontmatterLineOffset + lineOffset, + headlineAnchors: true }).buildConfiguredMarkdownIt(), - [onLineMarkerPositionChanged, useAlternativeBreaks, frontmatterLineOffset] + [onLineMarkerPositionChanged, useAlternativeBreaks, lineOffset] ) - - const baseReplacers = useComponentReplacers(onTaskCheckedChange, onImageClick, baseUrl, frontmatterLineOffset) - const replacers = useCallback( - () => baseReplacers().concat(additionalReplacers ? additionalReplacers() : []), - [additionalReplacers, baseReplacers] - ) - + const replacers = useComponentReplacers(onTaskCheckedChange, onImageClick, baseUrl, lineOffset) const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, markdownIt, replacers) useTranslation() @@ -99,4 +81,4 @@ export const BasicMarkdownRenderer: React.FC ComponentReplacer[]) => - useCallback( +): ComponentReplacer[] => + useMemo( () => [ new LinemarkerReplacer(), new GistReplacer(), diff --git a/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts b/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts index e8393a32b..3539aefc9 100644 --- a/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts +++ b/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts @@ -11,6 +11,7 @@ import { LineKeys } from '../types' import { buildTransformer } from '../utils/html-react-transformer' import { calculateNewLineNumberMapping } from '../utils/line-number-mapping' import convertHtmlToReact from '@hedgedoc/html-to-react' +import { Document } from 'domhandler' /** * Renders markdown code into react elements @@ -18,24 +19,19 @@ import convertHtmlToReact from '@hedgedoc/html-to-react' * @param markdownCode The markdown code that should be rendered * @param markdownIt The configured {@link MarkdownIt markdown it} instance that should render the code * @param replacers A function that provides a list of {@link ComponentReplacer component replacers} - * @param onBeforeRendering A callback that gets executed before the rendering - * @param onAfterRendering A callback that gets executed after the rendering + * @param preprocessNodes A function that processes nodes after parsing the html code that is generated by markdown it. * @return The React DOM that represents the rendered markdown code */ export const useConvertMarkdownToReactDom = ( markdownCode: string, markdownIt: MarkdownIt, - replacers: () => ComponentReplacer[], - onBeforeRendering?: () => void, - onAfterRendering?: () => void + replacers: ComponentReplacer[], + preprocessNodes?: (nodes: Document) => Document ): ValidReactDomElement[] => { const oldMarkdownLineKeys = useRef() const lastUsedLineId = useRef(0) return useMemo(() => { - if (onBeforeRendering) { - onBeforeRendering() - } const html = markdownIt.render(markdownCode) const contentLines = markdownCode.split('\n') const { lines: newLines, lastUsedLineId: newLastUsedLineId } = calculateNewLineNumberMapping( @@ -46,12 +42,7 @@ export const useConvertMarkdownToReactDom = ( oldMarkdownLineKeys.current = newLines lastUsedLineId.current = newLastUsedLineId - const currentReplacers = replacers() - const transformer = currentReplacers.length > 0 ? buildTransformer(newLines, currentReplacers) : undefined - const rendering = convertHtmlToReact(html, { transform: transformer }) - if (onAfterRendering) { - onAfterRendering() - } - return rendering - }, [onBeforeRendering, markdownIt, markdownCode, replacers, onAfterRendering]) + const transformer = replacers.length > 0 ? buildTransformer(newLines, replacers) : undefined + return convertHtmlToReact(html, { transform: transformer, preprocessNodes: preprocessNodes }) + }, [markdownIt, markdownCode, replacers, preprocessNodes]) } diff --git a/src/components/markdown-renderer/hooks/use-reveal.ts b/src/components/markdown-renderer/hooks/use-reveal.ts new file mode 100644 index 000000000..92a4b38fd --- /dev/null +++ b/src/components/markdown-renderer/hooks/use-reveal.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState } from 'react' +import Reveal from 'reveal.js' +import { Logger } from '../../../utils/logger' +import { SlideOptions } from '../../common/note-frontmatter/types' + +const log = new Logger('reveal.js') + +export const useReveal = (content: string, slideOptions?: SlideOptions): void => { + const [deck, setDeck] = useState() + const [isInitialized, setIsInitialized] = useState(false) + + useEffect(() => { + if (isInitialized) { + return + } + setIsInitialized(true) + log.debug('Initialize with slide options', slideOptions) + const reveal = new Reveal({}) + reveal + .initialize() + .then(() => { + reveal.layout() + reveal.slide(0, 0, 0) + setDeck(reveal) + log.debug('Initialisation finished') + }) + .catch((error) => { + log.error('Error while initializing reveal.js', error) + }) + }, [isInitialized, slideOptions]) + + useEffect(() => { + if (!deck) { + return + } + log.debug('Sync deck') + deck.layout() + }, [content, deck]) + + useEffect(() => { + if (!deck || slideOptions === undefined || Object.keys(slideOptions).length === 0) { + return + } + log.debug('Apply config', slideOptions) + deck.configure(slideOptions) + }, [deck, slideOptions]) +} diff --git a/src/components/markdown-renderer/markdown-it-configurator/basic-markdown-it-configurator.ts b/src/components/markdown-renderer/markdown-it-configurator/basic-markdown-it-configurator.ts index 36aabb002..85e768feb 100644 --- a/src/components/markdown-renderer/markdown-it-configurator/basic-markdown-it-configurator.ts +++ b/src/components/markdown-renderer/markdown-it-configurator/basic-markdown-it-configurator.ts @@ -35,12 +35,15 @@ 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 { addSlideSectionsMarkdownItPlugin } from '../markdown-it-plugins/reveal-sections' export interface ConfiguratorDetails { onToc: (toc: TocAst) => void onLineMarkers?: (lineMarkers: LineMarkers[]) => void useAlternativeBreaks?: boolean - offsetLines?: number + lineOffset?: number + headlineAnchors?: boolean + slideSections?: boolean } export class BasicMarkdownItConfigurator { @@ -73,7 +76,6 @@ export class BasicMarkdownItConfigurator { protected configure(markdownIt: MarkdownIt): void { this.configurations.push( plantumlWithError, - headlineAnchors, KatexReplacer.markdownItPlugin, YoutubeReplacer.markdownItPlugin, VimeoReplacer.markdownItPlugin, @@ -101,8 +103,16 @@ export class BasicMarkdownItConfigurator { spoilerContainer ) + if (this.options.headlineAnchors) { + this.configurations.push(headlineAnchors) + } + + if (this.options.slideSections) { + this.configurations.push(addSlideSectionsMarkdownItPlugin) + } + if (this.options.onLineMarkers) { - this.configurations.push(lineNumberMarker(this.options.onLineMarkers, this.options.offsetLines ?? 0)) + this.configurations.push(lineNumberMarker(this.options.onLineMarkers, this.options.lineOffset ?? 0)) } this.postConfigurations.push(linkifyExtra, MarkdownItParserDebugger) diff --git a/src/components/markdown-renderer/markdown-it-plugins/reveal-sections.ts b/src/components/markdown-renderer/markdown-it-plugins/reveal-sections.ts new file mode 100644 index 000000000..a0734f319 --- /dev/null +++ b/src/components/markdown-renderer/markdown-it-plugins/reveal-sections.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MarkdownIt from 'markdown-it/lib' +import Token from 'markdown-it/lib/token' +import StateCore from 'markdown-it/lib/rules_core/state_core' + +/** + * This functions adds a 'section close' token at currentTokenIndex in the state's token array, + * replacing the current token, if replaceCurrentToken is true. + * It also returns the currentTokenIndex, that will be increased only if the previous token was not replaced. + * + * @param {number} currentTokenIndex - the current position in the tokens array + * @param {StateCore} state - the state core + * @param {boolean} replaceCurrentToken - if the currentToken should be replaced + */ +const addSectionClose = (currentTokenIndex: number, state: StateCore, replaceCurrentToken: boolean): void => { + const sectionCloseToken = new Token('section', 'section', -1) + state.tokens.splice(currentTokenIndex, replaceCurrentToken ? 1 : 0, sectionCloseToken) +} + +/** + * This functions adds a 'section open' token at insertIndex in the state's token array. + * + * @param {number} insertIndex - the index at which the token should be added + * @param {StateCore} state - the state core + */ +const addSectionOpen = (insertIndex: number, state: StateCore): void => { + const sectionOpenToken = new Token('section', 'section', 1) + state.tokens.splice(insertIndex, 0, sectionOpenToken) +} + +/** + * Adds a plugin to the given {@link MarkdownIt markdown it instance} that + * replaces splits the content by horizontal lines and groups these blocks into + * html section tags. + * + * @param markdownIt The {@link MarkdownIt markdown it instance} to which the plugin should be added + */ +export const addSlideSectionsMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt): void => { + markdownIt.core.ruler.push('reveal.sections', (state) => { + let sectionBeginIndex = 0 + let lastSectionWasBranch = false + + for (let currentTokenIndex = 0; currentTokenIndex < state.tokens.length; currentTokenIndex++) { + const currentToken = state.tokens[currentTokenIndex] + + if (currentToken.type !== 'hr') { + continue + } + + addSectionOpen(sectionBeginIndex, state) + currentTokenIndex += 1 + + if (currentToken.markup === '---' && lastSectionWasBranch) { + lastSectionWasBranch = false + addSectionClose(currentTokenIndex, state, false) + currentTokenIndex += 1 + } else if (currentToken.markup === '----' && !lastSectionWasBranch) { + lastSectionWasBranch = true + addSectionOpen(sectionBeginIndex, state) + currentTokenIndex += 1 + } + + addSectionClose(currentTokenIndex, state, true) + sectionBeginIndex = currentTokenIndex + 1 + } + + addSectionOpen(sectionBeginIndex, state) + addSectionClose(state.tokens.length, state, false) + if (lastSectionWasBranch) { + addSectionClose(state.tokens.length, state, false) + } + return true + }) +} diff --git a/src/components/markdown-renderer/process-reveal-comment-nodes.ts b/src/components/markdown-renderer/process-reveal-comment-nodes.ts new file mode 100644 index 000000000..b820e771d --- /dev/null +++ b/src/components/markdown-renderer/process-reveal-comment-nodes.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { DataNode, Document, Element, hasChildren, isComment, isTag, Node } from 'domhandler' +import { Logger } from '../../utils/logger' + +const log = new Logger('reveal.js > Comment Node Preprocessor') +const revealCommandSyntax = /^\s*\.(\w*):(.*)$/g +const dataAttributesSyntax = /\s*([\w-]*)=(?:"((?:[^"\\]|\\"|\\)*)"|'([^']*)')/g + +/** + * Travels through the given {@link Document}, searches for reveal command comments and applies them. + * + * @param doc The document that should be changed + * @return The edited document + */ +export const processRevealCommentNodes = (doc: Document): Document => { + visitNode(doc) + return doc +} + +/** + * Processes the given {@link Node} if it is a comment node. If the node has children then all child nodes will be processed. + * @param node The node to process. + */ +const visitNode = (node: Node): void => { + if (isComment(node)) { + processCommentNode(node) + } else if (hasChildren(node)) { + node.childNodes.forEach((childNode) => visitNode(childNode)) + } +} + +/** + * Processes the given {@link DataNode html comment} by parsing it, finding the element that should be changed and applies the contained changes. + * + * @param node The node that contains the reveal command. + */ +const processCommentNode = (node: DataNode): void => { + const regexResult = node.data.split(revealCommandSyntax) + if (regexResult.length === 1) { + return + } + + const parentNode: Element | null = findTargetElement(node, regexResult[1]) + if (!parentNode) { + return + } + + for (const dataAttribute of regexResult[2].matchAll(dataAttributesSyntax)) { + const attributeName = dataAttribute[1] + const attributeValue = dataAttribute[2] ?? dataAttribute[3] + if (attributeValue) { + log.debug( + `Add attribute "${attributeName}"=>"${attributeValue}" to node`, + parentNode, + 'because of', + regexResult[1], + 'selector' + ) + parentNode.attribs[attributeName] = attributeValue + } + } +} + +/** + * Finds the ancestor element that should be changed based on the given selector. + * + * @param node The node whose ancestor should be found. + * @param selector The found ancestor node or null if no node could be found. + */ +const findTargetElement = (node: Node, selector: string): Element | null => { + if (selector === 'slide') { + return findNearestAncestorSection(node) + } else if (selector === 'element') { + return findParentElement(node) + } else { + return null + } +} + +/** + * Returns the parent node if it is an {@link Element}. + * + * @param node the found node or null if no parent node exists or if the parent node isn't an {@link Element}. + */ +const findParentElement = (node: Node): Element | null => { + return node.parentNode !== null && isTag(node.parentNode) ? node.parentNode : null +} + +/** + * Looks for the nearest ancestor of the node that is a section element. + * + * @param node the found section node or null if no section ancestor could be found. + */ +const findNearestAncestorSection = (node: Node): Element | null => { + let currentNode = node.parentNode + while (currentNode != null) { + if (isTag(currentNode) && currentNode.tagName === 'section') { + break + } + currentNode = node.parentNode + } + return currentNode +} diff --git a/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.scss b/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.scss index fda62b852..dcc8745ef 100644 --- a/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.scss +++ b/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.scss @@ -11,6 +11,8 @@ @import '../../../../../../node_modules/highlight.js/styles/github-dark'; } + position: relative; + code.hljs { overflow-x: auto; background-color: rgba(27, 31, 35, .05); @@ -50,10 +52,10 @@ .linenumber { display: flex; } - } - &.showGutter .codeline { - margin: 0 0 0 16px; + .codeline { + margin: 0 0 0 16px; + } } &.wrapLines .codeline { 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 d48416d35..9b0e33b6e 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,13 +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, offsetLines: number) => MarkdownIt.PluginSimple = - (options, offsetLines = 0) => +export const lineNumberMarker: (options: LineNumberMarkerOptions, lineOffset: number) => MarkdownIt.PluginSimple = + (options, lineOffset = 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, offsetLines) + tagTokens(state.tokens, lineMarkers, lineOffset) if (options) { options(lineMarkers) } @@ -57,7 +57,7 @@ export const lineNumberMarker: (options: LineNumberMarkerOptions, offsetLines: n tokens.splice(tokenPosition, 0, startToken) } - const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[], offsetLines: number) => { + const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[], lineOffset: number) => { for (let tokenPosition = 0; tokenPosition < tokens.length; tokenPosition++) { const token = tokens[tokenPosition] if (token.hidden) { @@ -72,14 +72,14 @@ export const lineNumberMarker: (options: LineNumberMarkerOptions, offsetLines: n const endLineNumber = token.map[1] + 1 if (token.level === 0) { - lineMarkers.push({ startLine: startLineNumber + offsetLines, endLine: endLineNumber + offsetLines }) + lineMarkers.push({ startLine: startLineNumber + lineOffset, endLine: endLineNumber + lineOffset }) } insertNewLineMarker(startLineNumber, endLineNumber, tokenPosition, token.level, tokens) tokenPosition += 1 if (token.children) { - tagTokens(token.children, lineMarkers, offsetLines) + tagTokens(token.children, lineMarkers, lineOffset) } } } diff --git a/src/components/markdown-renderer/replace-components/markmap/markmap-frame.tsx b/src/components/markdown-renderer/replace-components/markmap/markmap-frame.tsx index 3455bbffa..16bd13a36 100644 --- a/src/components/markdown-renderer/replace-components/markmap/markmap-frame.tsx +++ b/src/components/markdown-renderer/replace-components/markmap/markmap-frame.tsx @@ -66,7 +66,7 @@ export const MarkmapFrame: React.FC = ({ code }) => { }, [code]) return ( -
+
= ({ + className, + content, + onFirstHeadingChange, + onTaskCheckedChange, + onTocChange, + baseUrl, + onImageClick, + useAlternativeBreaks, + lineOffset, + slideOptions +}) => { + const markdownBodyRef = useRef(null) + const tocAst = useRef() + const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content) + + const markdownIt = useMemo( + () => + new BasicMarkdownItConfigurator({ + onToc: (toc) => (tocAst.current = toc), + useAlternativeBreaks, + lineOffset, + headlineAnchors: false, + slideSections: true + }).buildConfiguredMarkdownIt(), + [lineOffset, useAlternativeBreaks] + ) + const replacers = useComponentReplacers(onTaskCheckedChange, onImageClick, baseUrl, lineOffset) + const markdownReactDom = useConvertMarkdownToReactDom( + trimmedContent, + markdownIt, + replacers, + processRevealCommentNodes + ) + + useExtractFirstHeadline(markdownBodyRef, content, onFirstHeadingChange) + useOnRefChange(tocAst, onTocChange) + useReveal(content, slideOptions) + + return ( + + +
+
+ {markdownReactDom} +
+
+
+ ) +} + +export default SlideshowMarkdownRenderer diff --git a/src/components/markdown-renderer/slideshow.scss b/src/components/markdown-renderer/slideshow.scss new file mode 100644 index 000000000..1e8dadfe0 --- /dev/null +++ b/src/components/markdown-renderer/slideshow.scss @@ -0,0 +1,14 @@ +/*! + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@import "../../../node_modules/reveal.js/css/reveal"; +@import "../../../node_modules/reveal.js/dist/theme/fonts/league-gothic/league-gothic.css"; +@import "slide-theme"; + +//Fix to make transitions work with bootstrap +.reveal [hidden] { + display: block !important; +} diff --git a/src/components/markdown-renderer/types.d.ts b/src/components/markdown-renderer/types.d.ts index 7a04c43d0..01f0f3eea 100644 --- a/src/components/markdown-renderer/types.d.ts +++ b/src/components/markdown-renderer/types.d.ts @@ -13,8 +13,3 @@ export interface LineMarkerPosition { line: number position: number } - -export interface AdditionalMarkdownRendererProps { - className?: string - content: string -} diff --git a/src/components/markdown-renderer/utils/button-inside.scss b/src/components/markdown-renderer/utils/button-inside.scss index 79048f3a5..5b15cded7 100644 --- a/src/components/markdown-renderer/utils/button-inside.scss +++ b/src/components/markdown-renderer/utils/button-inside.scss @@ -5,5 +5,7 @@ */ .button-inside { - margin-top: -31px; + position: absolute; + bottom: 10px; + right: 10px; } diff --git a/src/components/editor-page/synced-scroll/hooks/use-synced-scrolling.ts b/src/components/render-page/hooks/sync-scroll/use-document-sync-scrolling.ts similarity index 92% rename from src/components/editor-page/synced-scroll/hooks/use-synced-scrolling.ts rename to src/components/render-page/hooks/sync-scroll/use-document-sync-scrolling.ts index 9533dda0a..1b5885370 100644 --- a/src/components/editor-page/synced-scroll/hooks/use-synced-scrolling.ts +++ b/src/components/render-page/hooks/sync-scroll/use-document-sync-scrolling.ts @@ -6,11 +6,11 @@ import React, { useCallback, useState } from 'react' import { LineMarkerPosition } from '../../../markdown-renderer/types' -import { ScrollState } from '../scroll-props' +import { ScrollState } from '../../../editor-page/synced-scroll/scroll-props' import { useOnUserScroll } from './use-on-user-scroll' import { useScrollToLineMark } from './use-scroll-to-line-mark' -export const useSyncedScrolling = ( +export const useDocumentSyncScrolling = ( outerContainerRef: React.RefObject, rendererRef: React.RefObject, numberOfLines: number, diff --git a/src/components/editor-page/synced-scroll/hooks/use-on-user-scroll.ts b/src/components/render-page/hooks/sync-scroll/use-on-user-scroll.ts similarity index 96% rename from src/components/editor-page/synced-scroll/hooks/use-on-user-scroll.ts rename to src/components/render-page/hooks/sync-scroll/use-on-user-scroll.ts index 2f4322050..ffe9ee296 100644 --- a/src/components/editor-page/synced-scroll/hooks/use-on-user-scroll.ts +++ b/src/components/render-page/hooks/sync-scroll/use-on-user-scroll.ts @@ -6,7 +6,7 @@ import { RefObject, useCallback } from 'react' import { LineMarkerPosition } from '../../../markdown-renderer/types' -import { ScrollState } from '../scroll-props' +import { ScrollState } from '../../../editor-page/synced-scroll/scroll-props' export const useOnUserScroll = ( lineMarks: LineMarkerPosition[] | undefined, diff --git a/src/components/editor-page/synced-scroll/hooks/use-scroll-to-line-mark.ts b/src/components/render-page/hooks/sync-scroll/use-scroll-to-line-mark.ts similarity index 93% rename from src/components/editor-page/synced-scroll/hooks/use-scroll-to-line-mark.ts rename to src/components/render-page/hooks/sync-scroll/use-scroll-to-line-mark.ts index a0e9b8d25..9e37abf6f 100644 --- a/src/components/editor-page/synced-scroll/hooks/use-scroll-to-line-mark.ts +++ b/src/components/render-page/hooks/sync-scroll/use-scroll-to-line-mark.ts @@ -6,8 +6,8 @@ import { RefObject, useCallback, useEffect, useRef } from 'react' import { LineMarkerPosition } from '../../../markdown-renderer/types' -import { ScrollState } from '../scroll-props' -import { findLineMarks } from '../utils' +import { ScrollState } from '../../../editor-page/synced-scroll/scroll-props' +import { findLineMarks } from '../../../editor-page/synced-scroll/utils' export const useScrollToLineMark = ( scrollState: ScrollState | undefined, diff --git a/src/components/render-page/iframe-markdown-renderer.tsx b/src/components/render-page/iframe-markdown-renderer.tsx index 450f31b30..4ef54ede0 100644 --- a/src/components/render-page/iframe-markdown-renderer.tsx +++ b/src/components/render-page/iframe-markdown-renderer.tsx @@ -19,16 +19,14 @@ import { countWords } from './word-counter' import { RendererFrontmatterInfo } from '../common/note-frontmatter/types' import { useRendererToEditorCommunicator } from '../editor-page/render-context/renderer-to-editor-communicator-context-provider' import { useRendererReceiveHandler } from './window-post-message-communicator/hooks/use-renderer-receive-handler' +import { SlideshowMarkdownRenderer } from '../markdown-renderer/slideshow-markdown-renderer' +import { initialState } from '../../redux/note-details/initial-state' 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 [frontmatterInfo, setFrontmatterInfo] = useState(initialState.frontmatterRendererInfo) const communicator = useRendererToEditorCommunicator() @@ -122,6 +120,18 @@ export const IframeMarkdownRenderer: React.FC = () => { frontmatterInfo={frontmatterInfo} /> ) + case RendererType.SLIDESHOW: + return ( + + ) case RendererType.INTRO: return ( = ({ }, [rendererSize.height, onHeightChange]) const contentLineCount = useMemo(() => markdownContent.split('\n').length, [markdownContent]) - const [onLineMarkerPositionChanged, onUserScroll] = useSyncedScrolling( + const [onLineMarkerPositionChanged, onUserScroll] = useDocumentSyncScrolling( internalDocumentRenderPaneRef, rendererRef, contentLineCount, @@ -88,7 +88,7 @@ export const MarkdownDocument: React.FC = ({
- = ({ baseUrl={baseUrl} onImageClick={onImageClick} useAlternativeBreaks={useAlternativeBreaks} - frontmatterLineOffset={frontmatterInfo?.offsetLines} + lineOffset={frontmatterInfo?.lineOffset} />
diff --git a/src/components/slide-show-page/slide-show-page.tsx b/src/components/slide-show-page/slide-show-page.tsx new file mode 100644 index 000000000..b84bd9e89 --- /dev/null +++ b/src/components/slide-show-page/slide-show-page.tsx @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React from 'react' +import { RenderIframe } from '../editor-page/renderer-pane/render-iframe' +import { useNoteMarkdownContent } from '../../hooks/common/use-note-markdown-content' +import { useTranslation } from 'react-i18next' +import { useLoadNoteFromServer } from '../editor-page/hooks/useLoadNoteFromServer' +import { ShowIf } from '../common/show-if/show-if' +import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' +import { RendererType } from '../render-page/window-post-message-communicator/rendering-message' +import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer' + +export const SlideShowPage: React.FC = () => { + const markdownContent = useNoteMarkdownContent() + + useTranslation() + useSendFrontmatterInfoFromReduxToRenderer() + + const [error, loading] = useLoadNoteFromServer() + + return ( + +
+ +
+
+ ) +} + +export default SlideShowPage diff --git a/src/external-types/reveal.js/index.d.ts b/src/external-types/reveal.js/index.d.ts new file mode 100644 index 000000000..43fe56318 --- /dev/null +++ b/src/external-types/reveal.js/index.d.ts @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module 'reveal.js' { + export interface RevealOptions { + // Configuration + controls?: boolean | undefined + progress?: boolean | undefined + // https://github.com/hakimel/reveal.js/#slide-number + slideNumber?: boolean | string | undefined + + history?: boolean | undefined + plugins?: Plugin[] | undefined + + // https://github.com/hakimel/reveal.js/#keyboard-bindings + // keyboard?: any + overview?: boolean | undefined + center?: boolean | undefined + touch?: boolean | undefined + loop?: boolean | undefined + rtl?: boolean | undefined + shuffle?: boolean | undefined + fragments?: boolean | undefined + embedded?: boolean | undefined + help?: boolean | undefined + showNotes?: boolean | undefined + autoSlide?: number | undefined + autoSlideStoppable?: boolean | undefined + // autoSlideMethod?: any + mouseWheel?: boolean | undefined + hideAddressBar?: boolean | undefined + previewLinks?: boolean | undefined + transition?: string | undefined + transitionSpeed?: string | undefined + backgroundTransition?: string | undefined + viewDistance?: number | undefined + + // https://github.com/hakimel/reveal.js/#parallax-background + // Parallax background image + parallaxBackgroundImage?: string | undefined + + // Parallax background size + parallaxBackgroundSize?: string | undefined // CSS syntax, e.g. "2100px 900px" - currently only pixels are supported (don't use % or auto) + + // Number of pixels to move the parallax background per slide + // - Calculated automatically unless specified + // - Set to 0 to disable movement along an axis + parallaxBackgroundHorizontal?: number | undefined + parallaxBackgroundVertical?: number | undefined + + rollingLinks?: boolean | undefined + theme?: string | undefined + + // Presentation Size + // https://github.com/hakimel/reveal.js/#presentation-size + width?: number | string | undefined + height?: number | string | undefined + margin?: number | string | undefined + minScale?: number | string | undefined + maxScale?: number | string | undefined + + // Dependencies + // https://github.com/hakimel/reveal.js/#dependencies + dependencies?: RevealDependency[] | undefined + + // Exposes the reveal.js API through window.postMessage + postMessage?: boolean | undefined + + // Dispatches all reveal.js events to the parent window through postMessage + postMessageEvents?: boolean | undefined + + // https://github.com/hakimel/reveal.js/#multiplexing + multiplex?: MultiplexConfig | undefined + + // https://github.com/hakimel/reveal.js/#mathjax + math?: MathConfig | undefined + } + + // https://github.com/hakimel/reveal.js/#slide-changed-event + export interface SlideEvent { + previousSlide?: Element | undefined + currentSlide: Element + indexh: number + indexv?: number | undefined + } + + // https://github.com/hakimel/reveal.js/#fragment-events + export interface FragmentEvent { + fragment: Element + } + + // https://github.com/hakimel/reveal.js/#multiplexing + export interface MultiplexConfig { + // Obtained from the socket.io server. Gives this (the master) control of the presentation + secret?: string | undefined + // Obtained from the socket.io server + id: string + + // Location of socket.io server + url: string + } + + // https://github.com/hakimel/reveal.js/#mathjax + export interface MathConfig { + // Obtained from the socket.io server. Gives this (the master) control of the presentation + mathjax: string + // Obtained from the socket.io server + config: string + } + + // https://github.com/hakimel/reveal.js/#dependencies + export interface RevealDependency { + src: string + condition?: (() => boolean) | undefined + async?: boolean | undefined + callback?: (() => void) | undefined + } + + export interface Plugin { + id: string + + init(deck: RevealStatic): void | Promise + } + + export default class Reveal { + constructor(options: RevealOptions) + initialize: () => Promise + + public configure: (diff: RevealOptions) => void + + // Navigation + public slide(indexh: number, indexv?: number, f?: number, o?: number): void + + public left(): void + + public right(): void + + public up(): void + + public down(): void + + public prev(): void + + public next(): void + + public prevFragment(): boolean + + public nextFragment(): boolean + + // Randomize the order of slides + public shuffle(): void + + // Toggle presentation states + public toggleOverview(override?: boolean): void + + public togglePause(override?: boolean): void + + public toggleAutoSlide(override?: boolean): void + + // Retrieves the previous and current slide elements + public getPreviousSlide(): Element + + public getCurrentSlide(): Element + + public getIndices(slide?: Element): { h: number; v: number } + + public getProgress(): number + + public getTotalSlides(): number + + public availableFragments(): { prev: boolean; next: boolean } + + // Returns the speaker notes for the current slide + public getSlideNotes(slide?: Element): string + + // Plugins + public hasPlugin(name: string): boolean + + public getPlugin(name: string): Plugin + + public getPlugins(): { [name: string]: Plugin } + + // States + // public addEventListener(type: string, listener: (event: any) => void, useCapture?: boolean): void + + // public removeEventListener(type: string, listener: (event: any) => void, useCapture?: boolean): void + + // State Checks + public isFirstSlide(): boolean + + public isLastSlide(): boolean + + public isPaused(): boolean + + public isOverview(): boolean + + public isAutoSliding(): boolean + + // undocumented method + public layout(): void + + public addEventListeners(): void + + public removeEventListeners(): void + + public getSlide(x: number, y?: number): Element + + public getScale(): number + + public getConfig(): RevealOptions + + // public getQueryHash(): any + + // public setState(state: any): void + + // public getState(): any + + // update slides after dynamic changes + public sync(): void + } +} + +// [1]: Implement ourself can only modify the iframe url diff --git a/src/hooks/common/use-note-markdown-content-without-frontmatter.ts b/src/hooks/common/use-note-markdown-content-without-frontmatter.ts index 35037ae3c..edb2940ef 100644 --- a/src/hooks/common/use-note-markdown-content-without-frontmatter.ts +++ b/src/hooks/common/use-note-markdown-content-without-frontmatter.ts @@ -14,7 +14,7 @@ import { useMemo } from 'react' */ export const useNoteMarkdownContentWithoutFrontmatter = (): string => { const markdownContent = useNoteMarkdownContent() - const offsetLines = useApplicationState((state) => state.noteDetails.frontmatterRendererInfo.offsetLines) + const lineOffset = useApplicationState((state) => state.noteDetails.frontmatterRendererInfo.lineOffset) - return useMemo(() => markdownContent.split('\n').slice(offsetLines).join('\n'), [markdownContent, offsetLines]) + return useMemo(() => markdownContent.split('\n').slice(lineOffset).join('\n'), [markdownContent, lineOffset]) } diff --git a/src/index.tsx b/src/index.tsx index 45f074994..b96ff9408 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -37,6 +37,11 @@ const DocumentReadOnlyPage = React.lazy( /* webpackPrefetch: true */ /* webpackChunkName: "documentReadOnly" */ './components/document-read-only-page/document-read-only-page' ) ) + +const SlideShowPage = React.lazy( + () => import(/* webpackPrefetch: true */ './components/slide-show-page/slide-show-page') +) + const baseUrl = new URL(document.head.baseURI).pathname const log = new Logger('Index') @@ -82,6 +87,9 @@ ReactDOM.render( + + + diff --git a/src/redux/note-details/initial-state.ts b/src/redux/note-details/initial-state.ts index 1f222a8fc..273d0aae7 100644 --- a/src/redux/note-details/initial-state.ts +++ b/src/redux/note-details/initial-state.ts @@ -6,7 +6,15 @@ import { NoteDetails } from './types' import { DateTime } from 'luxon' -import { NoteTextDirection, NoteType } from '../../components/common/note-frontmatter/types' +import { NoteTextDirection, NoteType, SlideOptions } from '../../components/common/note-frontmatter/types' + +export const initialSlideOptions: SlideOptions = { + transition: 'zoom', + autoSlide: 0, + autoSlideStoppable: true, + backgroundTransition: 'fade', + slideNumber: false +} export const initialState: NoteDetails = { markdownContent: '', @@ -14,7 +22,8 @@ export const initialState: NoteDetails = { frontmatterRendererInfo: { frontmatterInvalid: false, deprecatedSyntax: false, - offsetLines: 0 + lineOffset: 0, + slideOptions: initialSlideOptions }, id: '', createTime: DateTime.fromSeconds(0), @@ -39,6 +48,7 @@ export const initialState: NoteDetails = { GA: '', disqus: '', type: NoteType.DOCUMENT, - opengraph: new Map() + opengraph: new Map(), + slideOptions: initialSlideOptions } } diff --git a/src/redux/note-details/reducer.ts b/src/redux/note-details/reducer.ts index 4087cb9f2..03a648c7f 100644 --- a/src/redux/note-details/reducer.ts +++ b/src/redux/note-details/reducer.ts @@ -6,7 +6,10 @@ import { Reducer } from 'redux' import { PresentFrontmatterExtractionResult } from '../../components/common/note-frontmatter/types' -import { NoteFrontmatter } from '../../components/common/note-frontmatter/note-frontmatter' +import { + createNoteFrontmatterFromYaml, + 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' @@ -31,8 +34,6 @@ export const NoteDetailsReducer: Reducer = ( } } -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. @@ -43,6 +44,7 @@ const buildStateFromServerDto = (dto: NoteDto): NoteDetails => { 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. @@ -74,7 +76,7 @@ const buildStateFromTaskListUpdate = ( */ const buildStateFromMarkdownContentUpdate = (state: NoteDetails, markdownContent: string): NoteDetails => { const frontmatterExtraction = extractFrontmatter(markdownContent) - if (!frontmatterExtraction.frontmatterPresent) { + if (!frontmatterExtraction.isPresent) { return { ...state, markdownContent: markdownContent, @@ -103,32 +105,34 @@ const buildStateFromFrontmatterUpdate = ( state: NoteDetails, frontmatterExtraction: PresentFrontmatterExtractionResult ): NoteDetails => { - if (frontmatterExtraction.rawFrontmatterText === state.rawFrontmatter) { + if (frontmatterExtraction.rawText === state.rawFrontmatter) { return state } try { - const frontmatter = NoteFrontmatter.createFromYaml(frontmatterExtraction.rawFrontmatterText) + const frontmatter = createNoteFrontmatterFromYaml(frontmatterExtraction.rawText) return { ...state, - rawFrontmatter: frontmatterExtraction.rawFrontmatterText, + rawFrontmatter: frontmatterExtraction.rawText, frontmatter: frontmatter, noteTitle: generateNoteTitle(frontmatter, state.firstHeading), frontmatterRendererInfo: { - offsetLines: frontmatterExtraction.frontmatterLines, + lineOffset: frontmatterExtraction.lineOffset, deprecatedSyntax: frontmatter.deprecatedTagsSyntax, - frontmatterInvalid: false + frontmatterInvalid: false, + slideOptions: frontmatter.slideOptions } } } catch (e) { return { ...state, noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading), - rawFrontmatter: frontmatterExtraction.rawFrontmatterText, + rawFrontmatter: frontmatterExtraction.rawText, frontmatter: initialState.frontmatter, frontmatterRendererInfo: { - offsetLines: frontmatterExtraction.frontmatterLines, + lineOffset: frontmatterExtraction.lineOffset, deprecatedSyntax: false, - frontmatterInvalid: true + frontmatterInvalid: true, + slideOptions: initialState.frontmatterRendererInfo.slideOptions } } } @@ -172,11 +176,7 @@ const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => { return { markdownContent: note.content, rawFrontmatter: '', - frontmatterRendererInfo: { - frontmatterInvalid: false, - deprecatedSyntax: false, - offsetLines: 0 - }, + frontmatterRendererInfo: initialState.frontmatterRendererInfo, frontmatter: initialState.frontmatter, id: note.metadata.id, noteTitle: initialState.noteTitle, diff --git a/src/style/bootstrap.scss b/src/style/bootstrap.scss new file mode 100644 index 000000000..8ac60c182 --- /dev/null +++ b/src/style/bootstrap.scss @@ -0,0 +1,45 @@ +/*! + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** +Copy of the main bootstrap scss file but without transitions.scss because that clashes with reveal. + */ +@import "~bootstrap/scss/functions"; +@import "~bootstrap/scss/variables"; +@import "~bootstrap/scss/mixins"; +@import "~bootstrap/scss/root"; +@import "~bootstrap/scss/reboot"; +@import "~bootstrap/scss/type"; +@import "~bootstrap/scss/images"; +@import "~bootstrap/scss/code"; +@import "~bootstrap/scss/grid"; +@import "~bootstrap/scss/tables"; +@import "~bootstrap/scss/forms"; +@import "~bootstrap/scss/buttons"; +@import "~bootstrap/scss/dropdown"; +@import "~bootstrap/scss/button-group"; +@import "~bootstrap/scss/input-group"; +@import "~bootstrap/scss/custom-forms"; +@import "~bootstrap/scss/nav"; +@import "~bootstrap/scss/navbar"; +@import "~bootstrap/scss/card"; +@import "~bootstrap/scss/breadcrumb"; +@import "~bootstrap/scss/pagination"; +@import "~bootstrap/scss/badge"; +@import "~bootstrap/scss/jumbotron"; +@import "~bootstrap/scss/alert"; +@import "~bootstrap/scss/progress"; +@import "~bootstrap/scss/media"; +@import "~bootstrap/scss/list-group"; +@import "~bootstrap/scss/close"; +@import "~bootstrap/scss/toasts"; +@import "~bootstrap/scss/modal"; +@import "~bootstrap/scss/tooltip"; +@import "~bootstrap/scss/popover"; +@import "~bootstrap/scss/carousel"; +@import "~bootstrap/scss/spinners"; +@import "~bootstrap/scss/utilities"; +@import "~bootstrap/scss/print"; diff --git a/src/style/index.scss b/src/style/index.scss index f6e8d7505..9f47c70b3 100644 --- a/src/style/index.scss +++ b/src/style/index.scss @@ -6,7 +6,7 @@ @import "variables"; @import "variables.light"; -@import "../../node_modules/bootstrap/scss/bootstrap"; +@import "bootstrap"; @import '../../node_modules/react-bootstrap-typeahead/css/Typeahead'; @import "../../node_modules/@fontsource/source-sans-pro/index"; @import "../../node_modules/twemoji-colr-font/twemoji"; diff --git a/src/utils/test-modes.ts b/src/utils/test-modes.ts index 07570b268..018144512 100644 --- a/src/utils/test-modes.ts +++ b/src/utils/test-modes.ts @@ -11,3 +11,12 @@ export const isTestMode = (): boolean => { export const isMockMode = (): boolean => { return process.env.REACT_APP_BACKEND_BASE_URL === undefined } + +/** + * Checks if the current runtime was built in development mode. + * + * @return {@code true} if the runtime was built in development mode. + */ +export const isDevMode = (): boolean => { + return process.env.NODE_ENV === 'development' +} diff --git a/yarn.lock b/yarn.lock index 6dc93a607..cc3fda924 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12414,6 +12414,11 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +reveal.js@4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/reveal.js/-/reveal.js-4.1.3.tgz#f0a805a3cbc7c4e5553d66f91404b036f0f331cf" + integrity sha512-5VbL4nVDUedVKnOIIM3UQAIUlp+CvR/SrUkrN5GDoVfcWJAxH2oIh7PWyShy7+pE7tgkH2q+3e5EikGRpgE+oA== + rework-visit@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/rework-visit/-/rework-visit-1.0.0.tgz#9945b2803f219e2f7aca00adb8bc9f640f842c9a"