Skip to content

Commit

Permalink
feat(editor): Add support for collapsible sections
Browse files Browse the repository at this point in the history
Uses `<details>` and `<summary>` summary both for markdown and HTML
serialization.

Fixes: #3646

Signed-off-by: Jonas <jonas@freesources.org>
  • Loading branch information
mejo- committed Aug 27, 2024
1 parent 5806197 commit 005e19d
Show file tree
Hide file tree
Showing 15 changed files with 755 additions and 0 deletions.
58 changes: 58 additions & 0 deletions cypress/e2e/nodes/Details.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { initUserAndFiles, randUser } from '../../utils/index.js'

const user = randUser()
const fileName = 'empty.md'

describe('Details plugin', () => {
before(() => {
initUserAndFiles(user)
})

beforeEach(() => {
cy.login(user)

cy.isolateTest({
sourceFile: fileName,
})

return cy.openFile(fileName, { force: true })
})

it('inserts and removes details', () => {
cy.getContent()
.type('content{selectAll}')

cy.getMenuEntry('details').click()

cy.getContent()
.find('div[data-text-el="details"]')
.should('exist')

cy.getContent()
.type('summary')

cy.getContent()
.find('div[data-text-el="details"]')
.find('summary')
.should('contain', 'summary')

cy.getContent()
.find('div[data-text-el="details"]')
.find('.details-content')
.should('contain', 'content')

cy.getMenuEntry('details').click()

cy.getContent()
.find('div[data-text-el="details"]')
.should('not.exist')

cy.getContent()
.should('contain', 'content')
})
})
45 changes: 45 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"@nextcloud/eslint-config": "^8.4.1",
"@nextcloud/stylelint-config": "^3.0.1",
"@nextcloud/vite-config": "^1.4.2",
"@types/markdown-it": "^13.0.2",
"@vitejs/plugin-vue2": "^2.3.1",
"@vue/test-utils": "^1.3.0 <2",
"@vue/tsconfig": "^0.5.1",
Expand Down
11 changes: 11 additions & 0 deletions src/components/Menu/entries.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
Paperclip,
Positive,
Table,
UnfoldMoreHorizontal,
Warn,
} from '../icons.js'
import EmojiPickerAction from './EmojiPickerAction.vue'
Expand Down Expand Up @@ -322,6 +323,16 @@ export default [
},
priority: 17,
},
{
key: 'details',
label: t('text', 'Details'),
isActive: 'details',
icon: UnfoldMoreHorizontal,
action: (command) => {
return command.toggleDetails()
},
priority: 18,
},
{
key: 'emoji-picker',
label: t('text', 'Insert emoji'),
Expand Down
2 changes: 2 additions & 0 deletions src/components/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import MDI_TableAddRowBefore from 'vue-material-design-icons/TableRowPlusBefore.
import MDI_TableSettings from 'vue-material-design-icons/TableCog.vue'
import MDI_TrashCan from 'vue-material-design-icons/TrashCan.vue'
import MDI_Undo from 'vue-material-design-icons/ArrowULeftTop.vue'
import MDI_UnfoldMoreHorizontal from 'vue-material-design-icons/UnfoldMoreHorizontal.vue'
import MDI_Upload from 'vue-material-design-icons/Upload.vue'
import MDI_Warn from 'vue-material-design-icons/Alert.vue'
import MDI_Web from 'vue-material-design-icons/Web.vue'
Expand Down Expand Up @@ -131,6 +132,7 @@ export const TableSettings = makeIcon(MDI_TableSettings)
export const TrashCan = makeIcon(MDI_TrashCan)
export const TranslateVariant = makeIcon(MDI_TranslateVariant)
export const Undo = makeIcon(MDI_Undo)
export const UnfoldMoreHorizontal = makeIcon(MDI_UnfoldMoreHorizontal)
export const Upload = makeIcon(MDI_Upload)
export const Warn = makeIcon(MDI_Warn)
export const Web = makeIcon(MDI_Web)
2 changes: 2 additions & 0 deletions src/extensions/RichText.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Callouts from './../nodes/Callouts.js'
import CharacterCount from '@tiptap/extension-character-count'
import Code from '@tiptap/extension-code'
import CodeBlock from './../nodes/CodeBlock.js'
import Details from './../nodes/Details.js'
import Document from '@tiptap/extension-document'
import Dropcursor from '@tiptap/extension-dropcursor'
import EditableTable from './../nodes/EditableTable.js'
Expand Down Expand Up @@ -79,6 +80,7 @@ export default Extension.create({
lowlight,
defaultLanguage: 'plaintext',
}),
Details,
BulletList,
HorizontalRule,
OrderedList,
Expand Down
122 changes: 122 additions & 0 deletions src/markdownit/details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type MarkdownIt from 'markdown-it'
import type StateBlock from 'markdown-it/lib/rules_block/state_block'
import type Token from 'markdown-it/lib/token'

const DETAILS_START_REGEX = /^<details>\s*$/
const DETAILS_END_REGEX = /^<\/details>\s*$/
const SUMMARY_REGEX = /(?<=^<summary>).*(?=<\/summary>\s*$)/

function parseDetails(state: StateBlock, startLine: number, endLine: number, silent: boolean) {
// let autoClosedBlock = false
let start = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]

// Details block start
if (!state.src.slice(start, max).match(DETAILS_START_REGEX)) {
return false
}

// Since start is found, we can report success here in validation mode
if (silent) {
return true
}

let detailsFound = false
let detailsSummary = null
let nestedCount = 0
let nextLine = startLine
for (;;) {
nextLine++
if (nextLine >= endLine) {
break
}

start = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]

// Details summary
const m = state.src.slice(start, max).match(SUMMARY_REGEX)
if (m && detailsSummary === null) {
// Only set `detailsSummary` the first time
// Ignore future summary tags (in nested/broken details)
detailsSummary = m[0].trim()
continue
}

// Nested details
if (state.src.slice(start, max).match(DETAILS_START_REGEX)) {
nestedCount++
}

// Details block end
if (!state.src.slice(start, max).match(DETAILS_END_REGEX)) {
continue
}

// Regard nested details blocks
if (nestedCount > 0) {
nestedCount--
} else {
detailsFound = true
break
}
}

if (!detailsFound || detailsSummary === null) {
return false
}

const oldParent = state.parentType
const oldLineMax = state.lineMax
state.parentType = 'reference'

// This will prevent lazy continuations from ever going past our end marker
state.lineMax = nextLine;

// Push tokens to the state

let token = state.push('details_open', 'details', 1)
token.block = true
token.info = detailsSummary
token.map = [ startLine, nextLine ]

token = state.push('details_summary', 'summary', 1)
token.block = false

// Parse and push summary to preserve markup
let tokens: Token[] = []
state.md.inline.parse(detailsSummary, state.md, state.env, tokens)
for (const t of tokens) {
token = state.push(t.type, t.tag, t.nesting)
token.block = t.block
token.markup = t.markup
token.content = t.content
}

token = state.push('details_summary', 'summary', -1)

state.md.block.tokenize(state, startLine + 2, nextLine);

token = state.push('details_close', 'details', -1)
token.block = true

state.parentType = oldParent
state.lineMax = oldLineMax
state.line = nextLine + 1

return true
}

/**
* @param {object} md Markdown object
*/
export default function details(md: MarkdownIt) {
md.block.ruler.before('fence', 'details', parseDetails, {
alt: [ 'paragraph', 'reference', 'blockquote', 'list' ],
})
}
2 changes: 2 additions & 0 deletions src/markdownit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import markdownitMentions from '@quartzy/markdown-it-mentions'
import underline from './underline.js'
import splitMixedLists from './splitMixedLists.js'
import callouts from './callouts.js'
import details from './details.ts'
import preview from './preview.js'
import hardbreak from './hardbreak.js'
import keepSyntax from './keepSyntax.js'
Expand All @@ -25,6 +26,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
.use(underline)
.use(hardbreak)
.use(callouts)
.use(details)
.use(preview)
.use(keepSyntax)
.use(markdownitMentions)
Expand Down
Loading

0 comments on commit 005e19d

Please sign in to comment.