diff options
-rw-r--r-- | cypress/e2e/outline.spec.js | 74 | ||||
-rw-r--r-- | cypress/e2e/sections.spec.js | 124 | ||||
-rw-r--r-- | src/nodes/Heading/HeadingView.vue | 114 | ||||
-rw-r--r-- | src/nodes/Heading/index.js | 15 |
4 files changed, 74 insertions, 253 deletions
diff --git a/cypress/e2e/outline.spec.js b/cypress/e2e/outline.spec.js new file mode 100644 index 000000000..b441a65d6 --- /dev/null +++ b/cypress/e2e/outline.spec.js @@ -0,0 +1,74 @@ +import { randHash } from '../utils/index.js' + +const currentUser = randHash() + +const refresh = () => cy.get('.files-controls .crumb:not(.hidden) a') + .last() + .click({ force: true }) + +const clickOutline = () => { + cy.getActionEntry('headings') + .click() + + cy.get('.v-popper__wrapper .open').getActionEntry('outline') + .click() +} + +const createMarkdown = (fileName, content) => { + return cy.createFile(fileName, content, 'text/markdown') + .then(refresh) +} + +describe('Table of Contents', () => { + before(() => { + // Init user + cy.nextcloudCreateUser(currentUser, 'password') + cy.login(currentUser, 'password') + }) + + beforeEach(() => { + cy.login(currentUser, 'password') + }) + + it('sidebar toc', () => { + const fileName = 'toc.md' + + createMarkdown(fileName, '# T1 \n ## T2 \n ### T3 \n #### T4 \n ##### T5 \n ###### T6') + .then(refresh) + .then(() => cy.openFile(fileName, { force: true })) + .then(clickOutline) + + cy.getOutline() + .find('header') + .should('exist') + + cy.getTOC() + .find('ul li') + .should('have.length', 6) + cy.getTOC() + .find('ul li') + .each((el, index) => { + cy.wrap(el) + .should('have.attr', 'data-toc-level') + .and('equal', String(index + 1)) + + cy.wrap(el) + .find('a') + .should('have.attr', 'href') + .and('equal', `#t${index + 1}`) + }) + }) + + it('empty toc', () => { + const fileName = 'empty.md' + + createMarkdown(fileName, '') + .then(refresh) + .then(() => cy.openFile(fileName, { force: true })) + .then(clickOutline) + + cy.getOutline() + .find('ul') + .should('be.empty') + }) +}) diff --git a/cypress/e2e/sections.spec.js b/cypress/e2e/sections.spec.js deleted file mode 100644 index 89a3991a3..000000000 --- a/cypress/e2e/sections.spec.js +++ /dev/null @@ -1,124 +0,0 @@ -import { initUserAndFiles, randHash } from '../utils/index.js' - -const currentUser = randHash() -const fileName = 'test.md' - -const refresh = () => cy.get('.files-controls .crumb:not(.hidden) a') - .last() - .click({ force: true }) - -const clickOutline = () => { - cy.getActionEntry('headings') - .click() - - cy.get('.v-popper__wrapper .open').getActionEntry('outline') - .click() -} - -describe('Content Sections', () => { - before(function() { - initUserAndFiles(currentUser, fileName) - }) - - beforeEach(function() { - cy.login(currentUser, 'password', { - onBeforeLoad(win) { - cy.stub(win, 'open') - .as('winOpen') - }, - }) - - cy.openFile(fileName) - .then(() => cy.clearContent()) - }) - - describe('Heading anchors', () => { - beforeEach(() => cy.clearContent()) - - it('Anchor exists', () => { - cy.getContent() - .type('# Heading\nText\n## Heading 2\nText\n## Heading 2') - .then(() => { - cy.getContent() - .find('a.anchor-link') - .should(($anchor) => { - expect($anchor).to.have.length(3) - expect($anchor.eq(0)).to.have.attr('href').and.equal('#heading') - expect($anchor.eq(1)).to.have.attr('href').and.equal('#heading-2') - expect($anchor.eq(2)).to.have.attr('href').and.equal('#heading-2--1') - }) - }) - }) - - it('Anchor scrolls into view', () => { - // Create link to top heading - cy.getContent() - .type('{selectAll}{backspace}move top\n{selectAll}') - .get('.menububble button[data-text-bubble-action="add-link"]') - .click({ force: true }) - .then(() => { - cy.get('.menububble .menububble__input') - .type('{shift}') - .type('#top{enter}', { force: true }) - }) - // Insert content above link - cy.getContent() - .type('{moveToStart}\n{moveToStart}# top \n') - .type('lorem ipsum \n'.repeat(25)) - .type('{moveToEnd}\n') - .find('h1#top') - .should('not.be.inViewport') - // Click link and test view moved to anchor - cy.getContent() - .find('a:not(.anchor-link)') - .click() - .then(() => { - cy.getContent() - .get('h1[id="top"]') - .should('be.inViewport') - }) - }) - }) - - describe('Table of Contents', () => { - beforeEach(() => cy.clearContent()) - - it('sidebar toc', () => { - cy.getContent() - .type('# T1 \n## T2 \n### T3 \n#### T4 \n##### T5 \n###### T6\n') - .then(refresh) - .then(() => cy.openFile(fileName, { force: true })) - .then(clickOutline) - - cy.getOutline() - .find('header') - .should('exist') - - cy.getTOC() - .find('ul li') - .should('have.length', 6) - cy.getTOC() - .find('ul li') - .each((el, index) => { - cy.wrap(el) - .should('have.attr', 'data-toc-level') - .and('equal', String(index + 1)) - - cy.wrap(el) - .find('a') - .should('have.attr', 'href') - .and('equal', `#t${index + 1}`) - }) - }) - - it('empty toc', () => { - refresh() - .then(() => cy.openFile(fileName, { force: true })) - .then(clickOutline) - - cy.getOutline() - .find('ul') - .should('be.empty') - }) - }) -}) diff --git a/src/nodes/Heading/HeadingView.vue b/src/nodes/Heading/HeadingView.vue deleted file mode 100644 index 7e8b28e83..000000000 --- a/src/nodes/Heading/HeadingView.vue +++ /dev/null @@ -1,114 +0,0 @@ -<!-- - - @copyright Copyright (c) 2022 - - - - @license AGPL-3.0-or-later - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - -<template> - <NodeViewWrapper ref="content" - :as="domElement" - v-bind="node.attrs" - tabindex="-1"> - <a aria-hidden="true" - class="anchor-link" - :href="href" - :title="t('text', 'Link to this section')" - @click.stop="click">{{ linkSymbol }}</a> - <NodeViewContent /> - </NodeViewWrapper> -</template> - -<script> -import Vue from 'vue' -import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2' -import { useEditorMixin } from '../../components/Editor.provider.js' - -export default Vue.extend({ - name: 'HeadingView', - components: { - NodeViewWrapper, - NodeViewContent, - }, - mixins: [useEditorMixin], - props: { - node: { - type: Object, - required: true, - }, - extension: { - type: Object, - required: true, - }, - }, - - computed: { - href() { - return `#${this.node.attrs.id}` - }, - domElement() { - const hasLevel = this.extension.options.levels.includes(this.node.attrs.level) - const level = hasLevel ? this.node.attrs.level : this.extension.options.levels[0] - return `h${level}` - }, - linkSymbol() { - return this.extension.options.linkSymbol - }, - t: () => window.t, - }, - - methods: { - click() { - this.$refs.content.$el.scrollIntoView() - window.location.hash = this.href - }, - }, -}) -</script> - -<style scoped lang="scss"> -div.ProseMirror { - /* Anchor links */ - h1, h2, h3, h4, h5, h6 { - position: relative; - .anchor-link { - opacity: 0; - padding: 0; - left: -1em; - bottom: 0; - font-size: max(1em, 16px); - position: absolute; - text-decoration: none; - transition-duration: .15s; - transition-property: opacity; - transition-timing-function: cubic-bezier(.4,0,.2,1); - } - - &:hover .anchor-link { - opacity: 0.5; - } - } - - // Shrink clickable area of anchor permalinks while editing - &.ProseMirror-focused[contenteditable="true"] { - h1,h2,h3,h4,h5,h6 { - .anchor-link { - width: fit-content; - } - } - } -} -</style> diff --git a/src/nodes/Heading/index.js b/src/nodes/Heading/index.js index 63614da7e..ce7e73e78 100644 --- a/src/nodes/Heading/index.js +++ b/src/nodes/Heading/index.js @@ -1,18 +1,8 @@ import TipTapHeading from '@tiptap/extension-heading' -import { VueNodeViewRenderer } from '@tiptap/vue-2' import debounce from 'debounce' - -import HeadingView from './HeadingView.vue' import { setHeadings, extractHeadings } from './extractor.js' const Heading = TipTapHeading.extend({ - addOptions() { - return { - ...this.parent?.(), - linkSymbol: '#', - } - }, - addAttributes() { return { ...this.parent(), @@ -26,11 +16,6 @@ const Heading = TipTapHeading.extend({ }, } }, - - addNodeView() { - return VueNodeViewRenderer(HeadingView) - }, - addKeyboardShortcuts() { return this.options.levels.reduce((items, level) => ({ ...items, |