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 <[email protected]>
  • Loading branch information
mejo- committed Aug 23, 2024
1 parent 93f7f73 commit a31e5fb
Show file tree
Hide file tree
Showing 13 changed files with 581 additions and 0 deletions.
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
12 changes: 12 additions & 0 deletions src/components/Menu/entries.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Redo,
CodeTags,
Danger,
Details,
Emoticon,
FormatBold,
FormatItalic,
Expand Down Expand Up @@ -322,6 +323,17 @@ export default [
},
priority: 17,
},
{
key: 'details',
label: t('text', 'Details'),
isActive: 'details',
icon: Details,
action: (command) => {
// TODO: toggleDetails
return command.setDetails()
},
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 @@ -12,6 +12,7 @@ import MDI_CircleMedium from 'vue-material-design-icons/CircleMedium.vue'
import MDI_CodeTags from 'vue-material-design-icons/CodeTags.vue'
import MDI_Danger from 'vue-material-design-icons/AlertDecagram.vue'
import MDI_Delete from 'vue-material-design-icons/Delete.vue'
import MDI_Details from 'vue-material-design-icons/Details.vue'
import MDI_Document from 'vue-material-design-icons/FileDocument.vue'
import MDI_DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
import MDI_Emoticon from 'vue-material-design-icons/EmoticonOutline.vue'
Expand Down Expand Up @@ -93,6 +94,7 @@ export const CodeTags = makeIcon(MDI_CodeTags)
export const CircleMedium = makeIcon(MDI_CircleMedium)
export const Danger = makeIcon(MDI_Danger)
export const Delete = makeIcon(MDI_Delete)
export const Details = makeIcon(MDI_Details)
export const Document = makeIcon(MDI_Document)
export const DotsHorizontal = makeIcon(MDI_DotsHorizontal)
export const Emoticon = makeIcon(MDI_Emoticon)
Expand Down
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
111 changes: 111 additions & 0 deletions src/markdownit/details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* 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>/
const DETAILS_END_REGEX = /^<\/details>/
const SUMMARY_REGEX = /(?<=^<summary>).*(?=<\/summary>)/

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 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
}

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

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 a31e5fb

Please sign in to comment.