Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"lowlight": "^3.3.0",
"markdown-it": "^14.3.0",
"markdown-it-container": "^4.0.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-front-matter": "^0.2.4",
"markdown-it-image-figures": "^2.1.1",
"markdown-it-mark": "^4.0.0",
Expand Down Expand Up @@ -113,6 +114,7 @@
"@nextcloud/vite-config": "^2.5.4",
"@playwright/test": "^1.61.1",
"@types/markdown-it": "^14.1.2",
"@types/markdown-it-footnote": "^3.0.4",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.10",
"@vue/test-utils": "^2.4.11",
Expand Down
41 changes: 41 additions & 0 deletions playwright/e2e/footnotes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, mergeTests } from '@playwright/test'
import { test as editorTest } from '../support/fixtures/editor.ts'
import { test as uploadFileTest } from '../support/fixtures/upload-file.ts'

const test = mergeTests(editorTest, uploadFileTest)

test.describe('renders footnotes', () => {
test.use({
fileContent: 'Foo[^bar] baz\n\n[^bar]: footnote\n',
})

test('shows reference and footnote from Markdown', async ({ editor, open }) => {
await open()
await expect(editor.getFootnoteReference('bar')).toBeVisible()
await expect(editor.getFootnoteReference('bar').locator('a')).toHaveText('1')
await expect(editor.getFootnote('bar')).toContainText('footnote')
})
})

test('inserts footnote via keyboard shortcut', async ({ editor, open }) => {
await open()
await editor.type('Some text')
await editor.press('ControlOrMeta+Shift+F')
await expect(editor.getFootnoteReference('1')).toBeVisible()
await expect(editor.getFootnote('1')).toBeVisible()

await editor.type('footnote')
await expect(editor.getFootnote('1')).toContainText('footnote')
})

test('inserts footnote via [^label] input rule', async ({ editor, open }) => {
await open()
await editor.type('hello[^bar]')
await expect(editor.getFootnoteReference('bar')).toBeVisible()
await expect(editor.getFootnote('bar')).toBeVisible()
})
7 changes: 7 additions & 0 deletions playwright/support/sections/EditorSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export class EditorSection {
public readonly sessionList: Locator
public readonly suggestionsContainer: Locator
public readonly suggestions: Locator
public readonly footnoteReferences: Locator
public readonly footnotesSection: Locator

constructor(public readonly page: Page) {
this.el = this.page.locator('.editor').first()
Expand All @@ -34,6 +36,8 @@ export class EditorSection {
this.sessionList = this.el.locator('.session-list')
this.suggestionsContainer = this.page.locator('.container-suggestions')
this.suggestions = this.page.locator('.tippy-box .suggestion-list')
this.footnoteReferences = this.el.locator('sup[data-type="footnote-reference"]')
this.footnotesSection = this.el.locator('section[data-type="footnotes"]')
}

public async type(keys: string): Promise<void> {
Expand Down Expand Up @@ -74,4 +78,7 @@ export class EditorSection {

getHeading = (options: object = {}) => this.content.getByRole('heading', options)
getSuggestion = (name: string) => this.suggestions.getByText(name)

getFootnoteReference = (id: string) => this.footnoteReferences.locator(`:scope[data-reference-id="${id}"]`)
getFootnote = (id: string) => this.footnotesSection.locator(`[data-reference-id="${id}"]`)
}
17 changes: 15 additions & 2 deletions src/components/Editor/FloatingButtons.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
<DragHandle
:editor="editor"
class="floating-buttons"
:class="{ heading: isHeadingNode }"
:class="{
heading: isHeadingNode,
hidden: isHidden,
}"
:onNodeChange="onNodeChange">
<NcButton
variant="tertiary-no-background"
Expand Down Expand Up @@ -40,6 +43,8 @@ import DragVerticalIcon from 'vue-material-design-icons/DragVertical.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import { useEditor } from '../../composables/useEditor.ts'

const IGNORE_NODES = ['footnote', 'footnotes']

export default {
name: 'FloatingButtons',

Expand All @@ -64,7 +69,11 @@ export default {

computed: {
isHeadingNode() {
return this.node?.type === this.editor.schema.nodes.heading
return this.node?.type.name === 'heading'
},

isHidden() {
return IGNORE_NODES.includes(this.node?.type.name)
},
},

Expand Down Expand Up @@ -103,6 +112,10 @@ export default {
&.heading {
margin-right: 16px;
}

&.hidden {
display: none;
}
}

.drag-button {
Expand Down
15 changes: 14 additions & 1 deletion src/components/HelpModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,20 @@
</td>
</tr>
<tr>
<td>{{ t('text', 'Insert emoji') }}</td>
<td>{{ t('text', 'Footnote') }}</td>
<td>
<code>[^{{ t('text', 'label') }}]</code>
</td>
<td v-if="!isMobileCached">
<kbd>{{ ctrlOrModKey }}</kbd>
+
<kbd>{{ t('text', 'Shift') }}</kbd>
+
<kbd>F</kbd>
</td>
</tr>
<tr>
<td>{{ t('text', 'Emoji') }}</td>
<td>
<code>:{{ t('text', 'emoji') }}</code>
</td>
Expand Down
68 changes: 68 additions & 0 deletions src/css/prosemirror.scss
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,64 @@ div.ProseMirror {
z-index: 2;
}
}

sup[data-type="footnote-reference"] {
a.footnote-ref {
color: var(--color-primary-element);
text-decoration: none;

&:hover,
&:focus {
text-decoration: underline;
}
}
}

section[data-type="footnotes"] {
display: flex;
flex-direction: column;
margin-top: calc(2 * var(--default-grid-baseline));
background-color: var(--color-background-dark);
border-radius: var(--border-radius-container);
padding: calc(3 * var(--default-grid-baseline));
margin-inline: calc(3 * var(--default-grid-baseline) * -1);

div[data-type="footnote"] {
display: flex;
gap: var(--default-grid-baseline);

.footnote-label {
flex: 0 0 16px;
font-size: 0.9em;
font-weight: bold;
user-select: none;
overflow: hidden;
}

.footnote-body > p {
margin: 0;
line-height: unset;
}

.footnote-body-wrapper {
display: flex;
gap: var(--default-grid-baseline);
font-size: 0.9em;
}

.footnote-backref {
padding: 0;
color: var(--color-primary-element);
text-decoration: none;
width: unset; // overwrite default `a` width

&:hover,
&:focus {
text-decoration: underline;
}
}
}
}
}

.ProseMirror-focused .ProseMirror-gapcursor {
Expand All @@ -445,3 +503,13 @@ div.ProseMirror {
box-sizing: border-box;
visibility: visible !important;
}

.footnote-highlight {
animation: highlight-animation 5s 1;
border-radius: var(--border-radius-small);
}
@keyframes highlight-animation {
0% { background-color: var(--color-background-hover); }
50% { background-color: var(--color-background-hover); }
100% { background-color: transparent; }
}
11 changes: 9 additions & 2 deletions src/extensions/RichText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import Callouts from '../nodes/Callout.js'
import CodeBlock from '../nodes/CodeBlock.js'
import Details from '../nodes/Details.js'
import EditableTable from '../nodes/EditableTable.js'
import Footnotes from '../nodes/Footnotes.ts'
import FrontMatter from '../nodes/FrontMatter.js'
import HardBreak from '../nodes/HardBreak.js'
import Heading from '../nodes/Heading.js'
Expand Down Expand Up @@ -76,7 +77,9 @@ export default Extension.create({
addExtensions() {
const defaultExtensions = [
Markdown,
Document,
Document.extend({
content: 'block+ footnotes?',
}),
Text,
Paragraph,
HardBreak,
Expand All @@ -93,6 +96,7 @@ export default Extension.create({
defaultLanguage: 'plaintext',
}),
Details,
Footnotes,
BulletList,
HorizontalRule,
OrderedList,
Expand Down Expand Up @@ -140,12 +144,15 @@ export default Extension.create({
placeholder: t('text', "Start writing or type '/' to add…"),
})]
: []),
TrailingNode,
TrailingNode.configure({
notAfter: ['paragraph', 'footnotes'],
}),
TextDirection.configure({
types: [
'blockquote',
'callout',
'detailsSummary',
'footnote',
'heading',
'listItem',
'paragraph',
Expand Down
41 changes: 41 additions & 0 deletions src/markdownit/footnotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type MarkdownIt from 'markdown-it'
import type Token from 'markdown-it/lib/token.mjs'

import footnote from 'markdown-it-footnote'
import { escapeHtml } from 'markdown-it/lib/common/utils.mjs'

/**
* Return footnote label
*
* @param token markdown-it token
*/
function labelOf(token: Token): string {
return (token.meta && token.meta.label) || String(token.meta?.id ?? '')
}

/**
* Customized markdown-it footnotes extension
*
* @param md markdown-it markdown object
*/
export default function footnotes(md: MarkdownIt): void {
md.use(footnote)

md.renderer.rules.footnote_ref = (tokens, idx) => {
const label = labelOf(tokens[idx])
return `<sup data-type="footnote-reference" data-reference-id="${escapeHtml(label)}"></sup>`
}
md.renderer.rules.footnote_block_open = () => '<section data-type="footnotes">\n'
md.renderer.rules.footnote_block_close = () => '</section>\n'
md.renderer.rules.footnote_open = (tokens, idx) => {
const label = labelOf(tokens[idx])
return `<div data-type="footnote" data-reference-id="${escapeHtml(label)}">\n`
}
md.renderer.rules.footnote_close = () => '</div>\n'
md.renderer.rules.footnote_anchor = () => ''
}
2 changes: 2 additions & 0 deletions src/markdownit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import multimdTable from 'markdown-it-multimd-table'
import { escapeHtml } from 'markdown-it/lib/common/utils.mjs'
import callouts from './callouts.js'
import details from './details.ts'
import footnotes from './footnotes.ts'
import hardbreak from './hardbreak.js'
import keepSyntax from './keepSyntax.js'
import mathematics from './mathematics.ts'
Expand All @@ -32,6 +33,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
.use(hardbreak)
.use(callouts)
.use(details)
.use(footnotes)
.use(preview)
.use(keepSyntax)
.use(markdownitMentions)
Expand Down
Loading
Loading