diff --git a/cypress/e2e/sections.spec.js b/cypress/e2e/sections.spec.js
index c26161ebc03..cc486bb73fa 100644
--- a/cypress/e2e/sections.spec.js
+++ b/cypress/e2e/sections.spec.js
@@ -52,7 +52,7 @@ describe('Content Sections', () => {
cy.openFile(fileName, { force: true })
cy.getContent().type('# Heading 1{enter}')
cy.getContent()
- .find('h1')
+ .find('h1 > a')
.should('have.attr', 'id')
.and('equal', 'h-heading-1')
cy.getContent()
@@ -61,7 +61,7 @@ describe('Content Sections', () => {
.and('equal', '#h-heading-1')
cy.getContent().type('{backspace}{backspace}2{enter}')
cy.getContent()
- .find('h1')
+ .find('h1 > a')
.should('have.attr', 'id')
.and('equal', 'h-heading-2')
cy.getContent()
@@ -75,13 +75,13 @@ describe('Content Sections', () => {
cy.visitTestFolder()
cy.openFile('anchors.md')
cy.getContent()
- .get('h2[id="h-bottom"]')
+ .get('h2 > a[id="h-bottom"]')
.should('not.be.inViewport')
cy.getContent()
.find('a[href="#h-bottom"]:not(.heading-anchor)')
.click()
cy.getContent()
- .get('h2[id="h-bottom"]')
+ .get('h2 > a[id="h-bottom"]')
.should('be.inViewport')
})
@@ -92,15 +92,15 @@ describe('Content Sections', () => {
cy.getContent()
.type('# Heading 1{enter}')
cy.getContent()
- .find('h1')
+ .find('h1 > a')
.should('have.attr', 'id')
.and('equal', 'h-heading-1')
cy.getContent()
- .find('h1 [data-node-view-content]')
+ .find('h1')
.click({ force: true, position: 'center' })
cy.getActionEntry('headings').click()
cy.get('.v-popper__wrapper .open').getActionEntry('headings-h3').click()
- cy.getContent().find('h3')
+ cy.getContent().find('h3 > a')
.should('have.attr', 'id')
.and('equal', 'h-heading-1')
})
diff --git a/src/components/Editor/TableOfContents.vue b/src/components/Editor/TableOfContents.vue
index 2bc7f83130d..6d25d81bd80 100644
--- a/src/components/Editor/TableOfContents.vue
+++ b/src/components/Editor/TableOfContents.vue
@@ -7,7 +7,7 @@
-
- {{ linkSymbol }}
-
-
-
-
-
diff --git a/src/nodes/Heading/extractor.js b/src/nodes/Heading/extractor.js
deleted file mode 100644
index cb732041ba3..00000000000
--- a/src/nodes/Heading/extractor.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import store from '../../store/index.js'
-import { slugify } from './slug.js'
-import { v4 as uuidv4 } from 'uuid'
-
-const setHeadings = (val) => store.dispatch('text/setHeadings', val)
-
-const extractHeadings = (editor) => {
- const counter = new Map()
- const headings = []
- const tr = editor.state.tr
-
- const getId = text => {
- const id = slugify(text)
-
- if (counter.has(id)) {
- const next = counter.get(id)
-
- // increment counter
- counter.set(id, next + 1)
-
- return `h-${id}--${next}`
- }
-
- // define counter
- counter.set(id, 1)
-
- return 'h-' + id
- }
-
- editor.state.doc.descendants((node, position) => {
- if (node.type.name === 'heading') {
- const text = node.textContent
- const id = getId(text)
- const uuid = node.attrs.uuid ?? uuidv4()
-
- if (node.attrs.id !== id || !node.attrs.uuid) {
- const attrs = {
- ...node.attrs,
- uuid,
- id,
- }
-
- tr.setNodeMarkup(position, undefined, attrs)
- }
-
- headings.push(Object.freeze({
- level: node.attrs.level,
- position,
- text,
- id,
- uuid,
- }))
- }
- })
-
- tr.setMeta('addToHistory', false)
- tr.setMeta('preventUpdate', true)
-
- editor.view.dispatch(tr)
-
- setHeadings(headings)
-}
-
-export {
- extractHeadings,
- setHeadings,
-}
diff --git a/src/nodes/Heading/index.js b/src/nodes/Heading/index.js
deleted file mode 100644
index b9163aab33c..00000000000
--- a/src/nodes/Heading/index.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import TipTapHeading from '@tiptap/extension-heading'
-import { VueNodeViewRenderer } from '@tiptap/vue-2'
-import debounce from 'debounce'
-import { extractHeadings } from './extractor.js'
-import HeaderViewVue from './HeadingView.vue'
-
-const onUpdate = debounce(({ editor }) => {
- if (editor.view && editor.state && !editor.isDestroyed) {
- // Only run if editor still exists (prevent dangling debounced extractHeadings function)
- extractHeadings(editor)
- }
-}, 900, { immediate: true })
-
-const Heading = TipTapHeading.extend({
- addAttributes() {
- return {
- ...this.parent(),
- id: {
- default: undefined,
- rendered: true,
- },
- uuid: {
- default: undefined,
- rendered: false,
- },
- }
- },
-
- addOptions() {
- return {
- ...this.parent?.(),
- linkSymbol: '#',
- }
- },
-
- addKeyboardShortcuts() {
- return this.options.levels.reduce((items, level) => ({
- ...items,
- [`Mod-Shift-${level}`]: () => this.editor.commands.toggleHeading({ level }),
- }), {})
- },
-
- addNodeView() {
- return VueNodeViewRenderer(HeaderViewVue, {
- update: ({ oldNode, newNode, updateProps }) => {
- if (newNode.type.name !== this.name) return false
- // Make sure to redraw node as the vue renderer will not show the updated children
- if (newNode.attrs !== oldNode.attrs) return false
- updateProps()
- return true
- },
- })
- },
-
- onCreate() {
- extractHeadings(this.editor)
-
- if (this.parent) {
- this.parent()
- }
- },
-
- onUpdate: (event) => {
- onUpdate(event)
- },
-
-})
-
-export default Heading
diff --git a/src/plugins/extractHeadings.js b/src/plugins/extractHeadings.js
new file mode 100644
index 00000000000..a59f9277869
--- /dev/null
+++ b/src/plugins/extractHeadings.js
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { slugify } from './slug.js'
+
+/**
+ * Extract heading data structure from doc
+ *
+ * @param {Document} doc - the prosemirror doc
+ * @return {Array} headings found in the doc
+ */
+export default function extractHeadings(doc) {
+ const counter = new Map()
+ const headings = []
+
+ const getId = text => {
+ const id = slugify(text)
+ if (counter.has(id)) {
+ const next = counter.get(id)
+ // increment counter
+ counter.set(id, next + 1)
+ return `h-${id}--${next}`
+ }
+ // define counter
+ counter.set(id, 1)
+ return 'h-' + id
+ }
+
+ doc.descendants((node, offset) => {
+ if (node.type.name !== 'heading') {
+ return
+ }
+ const text = node.textContent
+ // ignore empty headings
+ if (!text) return
+ const id = getId(text)
+ headings.push(Object.freeze({
+ level: node.attrs.level,
+ text,
+ id,
+ offset,
+ }))
+ })
+
+ return headings
+}
diff --git a/src/plugins/headingAnchor.js b/src/plugins/headingAnchor.js
new file mode 100644
index 00000000000..c6886f7bd57
--- /dev/null
+++ b/src/plugins/headingAnchor.js
@@ -0,0 +1,122 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import extractHeadings from './extractHeadings.js'
+
+export const headingAnchorPluginKey = new PluginKey('headingAnchor')
+
+/**
+ * Heading anchor decorations ProseMirror plugin
+ * Add anchor tags with a unique id to all headings.
+ *
+ * @return {Plugin}
+ */
+export default function headingAnchor() {
+ return new Plugin({
+
+ key: headingAnchorPluginKey,
+
+ state: {
+ init(_, { doc }) {
+ const headings = extractHeadings(doc)
+ return {
+ headings,
+ decorations: anchorDecorations(doc, headings),
+ }
+ },
+ apply(tr, value, _oldState, newState) {
+ if (!tr.docChanged) {
+ return value
+ }
+ const headings = extractHeadings(newState.doc)
+ // if the headings are the same, keep the decorations
+ if (sameHeadings(headings, value.headings)) {
+ return {
+ headings,
+ decorations: value.decorations.map(tr.mapping, tr.doc),
+ }
+ }
+ return {
+ headings,
+ decorations: anchorDecorations(newState.doc, headings),
+ }
+ },
+ },
+
+ props: {
+ decorations(state) {
+ return this.getState(state).decorations
+ },
+ },
+ })
+}
+
+/**
+ * Check if the headings provided have the same ids.
+ *
+ * This is enough to ensure no updates are needed
+ * as the id includes a slugified version of the text
+ * and the level.
+ *
+ * @param {Array} current - array of headings
+ * @param {Array} other - headings to compare against
+ *
+ * @return {boolean}
+ */
+function sameHeadings(current, other) {
+ if (current.length !== other.length) return false
+ return current.every((heading, i) => heading.id === other[i].id)
+}
+
+/**
+ * Create anchor decorations for the given headings
+ * @param {Document} doc - prosemirror doc
+ * @param {Array} headings - headings structure in the doc
+ * @return {DecorationSet}
+ */
+function anchorDecorations(doc, headings) {
+ const decorations = headings.map(decorationForHeading)
+ return DecorationSet.create(doc, decorations)
+}
+
+/**
+ * Create a decoration for the given heading
+ * @param {object} heading to decorate
+ * @return {Decoration}
+ */
+function decorationForHeading(heading) {
+ return Decoration.widget(heading.offset + 1, anchorForHeading(heading))
+}
+
+/**
+ * Create an anchor element for the given heading
+ * @param {object} heading to generate anchor for
+ * @return {HTMLElement}
+ */
+function anchorForHeading(heading) {
+ const el = document.createElement('a')
+ const symbol = document.createTextNode('#')
+ el.appendChild(symbol)
+ el.setAttribute('id', heading.id)
+ el.setAttribute('aria-hidden', 'true')
+ el.className = 'heading-anchor'
+ el.setAttribute('href', `#${heading.id}`)
+ el.setAttribute('title', window.t('text', 'Link to this section'))
+ el.setAttribute('contenteditable', 'false')
+ el.addEventListener('click', handleClick)
+ return el
+}
+
+/**
+ * Handle click on an anchor - scroll into view and change location hash.
+ * @param {PointerEvent} event click that triggered the function
+ */
+function handleClick(event) {
+ event.stopPropagation()
+ event.target.scrollIntoView()
+ window.location.hash = event.target.getAttribute('href')
+}
diff --git a/src/nodes/Heading/slug.js b/src/plugins/slug.js
similarity index 100%
rename from src/nodes/Heading/slug.js
rename to src/plugins/slug.js
diff --git a/src/tests/plugins/extractHeadings.spec.js b/src/tests/plugins/extractHeadings.spec.js
new file mode 100644
index 00000000000..a5b61aab48c
--- /dev/null
+++ b/src/tests/plugins/extractHeadings.spec.js
@@ -0,0 +1,55 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import extractHeadings from '../../plugins/extractHeadings.js'
+import Heading from '../../nodes/Heading.js'
+import { createCustomEditor } from '../helpers.js'
+
+describe('extractHeadings', () => {
+
+ it('returns an empty array for an empty doc', () => {
+ const doc = prepareDoc('')
+ const headings = extractHeadings(doc)
+ expect(headings).toEqual([])
+ })
+
+ it('returns headings', () => {
+ const content = '
Level 1 heading
'
+ const doc = prepareDoc(content)
+ const headings = extractHeadings(doc)
+ expect(headings).toEqual([{
+ id: 'h-level-1-heading',
+ level: 1,
+ offset: 0,
+ text: 'Level 1 heading',
+ }])
+ })
+
+ it('ignores an empty heading', () => {
+ const content = ''
+ const doc = prepareDoc(content)
+ const headings = extractHeadings(doc)
+ expect(headings).toEqual([])
+ })
+
+ it('creates unique ids with a counter', () => {
+ const content = `
+ Level 1 heading
+ Level 1 heading
+ `
+ const doc = prepareDoc(content)
+ const headings = extractHeadings(doc)
+ expect(headings[1].id).toEqual('h-level-1-heading--1')
+ })
+
+})
+
+function prepareDoc(content) {
+ const editor = createCustomEditor({
+ content,
+ extensions: [ Heading ]
+ })
+ return editor.state.doc
+}