Skip to content

Commit d5c288b

Browse files
committed
feat(editor): Add support for collapsible sections
Uses `<details>` and `<summary>` summary both for markdown and HTML serialization. Fixes: #3646 Signed-off-by: Jonas <[email protected]>
1 parent 5806197 commit d5c288b

15 files changed

+754
-0
lines changed

cypress/e2e/nodes/Details.spec.js

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { initUserAndFiles, randUser } from '../../utils/index.js'
7+
8+
const user = randUser()
9+
const fileName = 'empty.md'
10+
11+
describe('Details plugin', () => {
12+
before(() => {
13+
initUserAndFiles(user)
14+
})
15+
16+
beforeEach(() => {
17+
cy.login(user)
18+
19+
cy.isolateTest({
20+
sourceFile: fileName,
21+
})
22+
23+
return cy.openFile(fileName, { force: true })
24+
})
25+
26+
it('inserts and removes details', () => {
27+
cy.getContent()
28+
.type('content{selectAll}')
29+
30+
cy.getMenuEntry('details').click()
31+
32+
cy.getContent()
33+
.find('div[data-text-el="details"]')
34+
.should('exist')
35+
36+
cy.getContent()
37+
.type('summary')
38+
39+
cy.getContent()
40+
.find('div[data-text-el="details"]')
41+
.find('summary')
42+
.should('contain', 'summary')
43+
44+
cy.getContent()
45+
.find('div[data-text-el="details"]')
46+
.find('.details-content')
47+
.should('contain', 'content')
48+
49+
cy.getMenuEntry('details').click()
50+
51+
cy.getContent()
52+
.find('div[data-text-el="details"]')
53+
.should('not.exist')
54+
55+
cy.getContent()
56+
.should('contain', 'content')
57+
})
58+
})

package-lock.json

+45
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@
123123
"@nextcloud/eslint-config": "^8.4.1",
124124
"@nextcloud/stylelint-config": "^3.0.1",
125125
"@nextcloud/vite-config": "^1.4.2",
126+
"@types/markdown-it": "^13.0.2",
126127
"@vitejs/plugin-vue2": "^2.3.1",
127128
"@vue/test-utils": "^1.3.0 <2",
128129
"@vue/tsconfig": "^0.5.1",

src/components/Menu/entries.js

+11
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
Paperclip,
2929
Positive,
3030
Table,
31+
UnfoldMoreHorizontal,
3132
Warn,
3233
} from '../icons.js'
3334
import EmojiPickerAction from './EmojiPickerAction.vue'
@@ -322,6 +323,16 @@ export default [
322323
},
323324
priority: 17,
324325
},
326+
{
327+
key: 'details',
328+
label: t('text', 'Details'),
329+
isActive: 'details',
330+
icon: UnfoldMoreHorizontal,
331+
action: (command) => {
332+
return command.toggleDetails()
333+
},
334+
priority: 18,
335+
},
325336
{
326337
key: 'emoji-picker',
327338
label: t('text', 'Insert emoji'),

src/components/icons.js

+2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import MDI_TableAddRowBefore from 'vue-material-design-icons/TableRowPlusBefore.
5353
import MDI_TableSettings from 'vue-material-design-icons/TableCog.vue'
5454
import MDI_TrashCan from 'vue-material-design-icons/TrashCan.vue'
5555
import MDI_Undo from 'vue-material-design-icons/ArrowULeftTop.vue'
56+
import MDI_UnfoldMoreHorizontal from 'vue-material-design-icons/UnfoldMoreHorizontal.vue'
5657
import MDI_Upload from 'vue-material-design-icons/Upload.vue'
5758
import MDI_Warn from 'vue-material-design-icons/Alert.vue'
5859
import MDI_Web from 'vue-material-design-icons/Web.vue'
@@ -131,6 +132,7 @@ export const TableSettings = makeIcon(MDI_TableSettings)
131132
export const TrashCan = makeIcon(MDI_TrashCan)
132133
export const TranslateVariant = makeIcon(MDI_TranslateVariant)
133134
export const Undo = makeIcon(MDI_Undo)
135+
export const UnfoldMoreHorizontal = makeIcon(MDI_UnfoldMoreHorizontal)
134136
export const Upload = makeIcon(MDI_Upload)
135137
export const Warn = makeIcon(MDI_Warn)
136138
export const Web = makeIcon(MDI_Web)

src/extensions/RichText.js

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Callouts from './../nodes/Callouts.js'
1313
import CharacterCount from '@tiptap/extension-character-count'
1414
import Code from '@tiptap/extension-code'
1515
import CodeBlock from './../nodes/CodeBlock.js'
16+
import Details from './../nodes/Details.js'
1617
import Document from '@tiptap/extension-document'
1718
import Dropcursor from '@tiptap/extension-dropcursor'
1819
import EditableTable from './../nodes/EditableTable.js'
@@ -79,6 +80,7 @@ export default Extension.create({
7980
lowlight,
8081
defaultLanguage: 'plaintext',
8182
}),
83+
Details,
8284
BulletList,
8385
HorizontalRule,
8486
OrderedList,

src/markdownit/details.ts

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type MarkdownIt from 'markdown-it'
7+
import type StateBlock from 'markdown-it/lib/rules_block/state_block'
8+
import type Token from 'markdown-it/lib/token'
9+
10+
const DETAILS_START_REGEX = /^<details>\s*$/
11+
const DETAILS_END_REGEX = /^<\/details>\s*$/
12+
const SUMMARY_REGEX = /(?<=^<summary>).*(?=<\/summary>\s*$)/
13+
14+
function parseDetails(state: StateBlock, startLine: number, endLine: number, silent: boolean) {
15+
// let autoClosedBlock = false
16+
let start = state.bMarks[startLine] + state.tShift[startLine]
17+
let max = state.eMarks[startLine]
18+
19+
// Details block start
20+
if (!state.src.slice(start, max).match(DETAILS_START_REGEX)) {
21+
return false
22+
}
23+
24+
// Since start is found, we can report success here in validation mode
25+
if (silent) {
26+
return true
27+
}
28+
29+
let detailsFound = false
30+
let detailsSummary = null
31+
let nestedCount = 0
32+
let nextLine = startLine
33+
for (;;) {
34+
nextLine++
35+
if (nextLine >= endLine) {
36+
break
37+
}
38+
39+
start = state.bMarks[nextLine] + state.tShift[nextLine]
40+
max = state.eMarks[nextLine]
41+
42+
// Details summary
43+
const m = state.src.slice(start, max).match(SUMMARY_REGEX)
44+
if (m && detailsSummary === null) {
45+
// Only set `detailsSummary` the first time
46+
// Ignore future summary tags (in nested/broken details)
47+
detailsSummary = m[0].trim()
48+
continue
49+
}
50+
51+
// Nested details
52+
if (state.src.slice(start, max).match(DETAILS_START_REGEX)) {
53+
nestedCount++
54+
}
55+
56+
// Details block end
57+
if (!state.src.slice(start, max).match(DETAILS_END_REGEX)) {
58+
continue
59+
}
60+
61+
// Regard nested details blocks
62+
if (nestedCount > 0) {
63+
nestedCount--
64+
} else {
65+
detailsFound = true
66+
break
67+
}
68+
}
69+
70+
if (!detailsFound || detailsSummary === null) {
71+
return false
72+
}
73+
74+
const oldParent = state.parentType
75+
const oldLineMax = state.lineMax
76+
state.parentType = 'reference'
77+
78+
// This will prevent lazy continuations from ever going past our end marker
79+
state.lineMax = nextLine;
80+
81+
// Push tokens to the state
82+
83+
let token = state.push('details_open', 'details', 1)
84+
token.block = true
85+
token.info = detailsSummary
86+
token.map = [ startLine, nextLine ]
87+
88+
token = state.push('details_summary', 'summary', 1)
89+
token.block = false
90+
91+
// Parse and push summary to preserve markup
92+
let tokens: Token[] = []
93+
state.md.inline.parse(detailsSummary, state.md, state.env, tokens)
94+
for (const t of tokens) {
95+
token = state.push(t.type, t.tag, t.nesting)
96+
token.block = t.block
97+
token.markup = t.markup
98+
token.content = t.content
99+
}
100+
101+
token = state.push('details_summary', 'summary', -1)
102+
103+
state.md.block.tokenize(state, startLine + 2, nextLine);
104+
105+
token = state.push('details_close', 'details', -1)
106+
token.block = true
107+
108+
state.parentType = oldParent
109+
state.lineMax = oldLineMax
110+
state.line = nextLine + 1
111+
112+
return true
113+
}
114+
115+
/**
116+
* @param {object} md Markdown object
117+
*/
118+
export default function details(md: MarkdownIt) {
119+
md.block.ruler.before('fence', 'details', parseDetails, {
120+
alt: [ 'paragraph', 'reference', 'blockquote', 'list' ],
121+
})
122+
}

src/markdownit/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import markdownitMentions from '@quartzy/markdown-it-mentions'
99
import underline from './underline.js'
1010
import splitMixedLists from './splitMixedLists.js'
1111
import callouts from './callouts.js'
12+
import details from './details.ts'
1213
import preview from './preview.js'
1314
import hardbreak from './hardbreak.js'
1415
import keepSyntax from './keepSyntax.js'
@@ -25,6 +26,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
2526
.use(underline)
2627
.use(hardbreak)
2728
.use(callouts)
29+
.use(details)
2830
.use(preview)
2931
.use(keepSyntax)
3032
.use(markdownitMentions)

0 commit comments

Comments
 (0)