зеркало из https://github.com/nextcloud/text.git
feat(editor): Add support for collapsible sections
Uses `<details>` and `<summary>` summary both for markdown and HTML serialization. Fixes: #3646 Signed-off-by: Jonas <jonas@freesources.org>
This commit is contained in:
Родитель
f6800ba829
Коммит
030313d770
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -95,6 +95,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",
|
||||
|
@ -6062,6 +6063,28 @@
|
|||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.2.tgz",
|
||||
"integrity": "sha512-Tla7hH9oeXHOlJyBFdoqV61xWE9FZf/y2g+gFVwQ2vE1/eBzjUno5JCd3Hdb5oATve5OF6xNjZ/4VIZhVVx+hA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "*",
|
||||
"@types/mdurl": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "0.7.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
|
||||
|
@ -32737,6 +32760,28 @@
|
|||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/markdown-it": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.2.tgz",
|
||||
"integrity": "sha512-Tla7hH9oeXHOlJyBFdoqV61xWE9FZf/y2g+gFVwQ2vE1/eBzjUno5JCd3Hdb5oATve5OF6xNjZ/4VIZhVVx+hA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/linkify-it": "*",
|
||||
"@types/mdurl": "*"
|
||||
}
|
||||
},
|
||||
"@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/ms": {
|
||||
"version": "0.7.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
Paperclip,
|
||||
Positive,
|
||||
Table,
|
||||
UnfoldMoreHorizontal,
|
||||
Warn,
|
||||
} from '../icons.js'
|
||||
import EmojiPickerAction from './EmojiPickerAction.vue'
|
||||
|
@ -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'),
|
||||
|
|
|
@ -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'
|
||||
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
@ -79,6 +80,7 @@ export default Extension.create({
|
|||
lowlight,
|
||||
defaultLanguage: 'plaintext',
|
||||
}),
|
||||
Details,
|
||||
BulletList,
|
||||
HorizontalRule,
|
||||
OrderedList,
|
||||
|
|
|
@ -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' ],
|
||||
})
|
||||
}
|
|
@ -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'
|
||||
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { isNodeActive, mergeAttributes, Node } from '@tiptap/core'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue-2'
|
||||
import DetailsContent from './DetailsContent.js'
|
||||
import DetailsSummary from './DetailsSummary.js'
|
||||
import DetailsView from './DetailsView.vue'
|
||||
|
||||
/**
|
||||
* Get first details node from parent nodes of a resolved position
|
||||
*
|
||||
* @param {object} resolvedPos - resolved position
|
||||
* @param {object} schema - prosemirror editor schema
|
||||
*/
|
||||
function detailsParentInfo(resolvedPos, schema) {
|
||||
for (let depth = resolvedPos.depth; depth > 0; depth -= 1) {
|
||||
const node = resolvedPos.node(depth)
|
||||
if (node.type === schema.nodes.details) {
|
||||
return {
|
||||
pos: depth > 0
|
||||
? resolvedPos.before(depth)
|
||||
: 0,
|
||||
node,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first detailsContent node from descendants of a node
|
||||
*
|
||||
* @param {object} node - prosemirror node
|
||||
* @param {object} schema - prosemirror editor schema
|
||||
*/
|
||||
function detailsContentNode(node, schema) {
|
||||
const detailsContentNodes = []
|
||||
node.descendants((childNode, i) => {
|
||||
if (childNode.type === schema.nodes.detailsContent) {
|
||||
detailsContentNodes.push(childNode)
|
||||
return false
|
||||
}
|
||||
})
|
||||
return detailsContentNodes.length > 0
|
||||
? detailsContentNodes[0]
|
||||
: null
|
||||
}
|
||||
|
||||
const Details = Node.create({
|
||||
name: 'details',
|
||||
content: 'detailsSummary detailsContent',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
allowGapCursor: false,
|
||||
|
||||
addExtensions() {
|
||||
return [
|
||||
DetailsContent,
|
||||
DetailsSummary,
|
||||
]
|
||||
},
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
open: {
|
||||
default: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'details',
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['details', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(DetailsView)
|
||||
},
|
||||
|
||||
toMarkdown: (state, node) => {
|
||||
state.write('<details>\n')
|
||||
state.renderContent(node)
|
||||
state.closeBlock(node)
|
||||
state.ensureNewLine()
|
||||
state.write('</details>')
|
||||
state.ensureNewLine()
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setDetails: () => ({ commands, state, chain }) => {
|
||||
const { schema, selection } = state
|
||||
const { $from, $to } = selection
|
||||
const blockRange = $from.blockRange($to)
|
||||
if (!blockRange) {
|
||||
return false
|
||||
}
|
||||
|
||||
const slice = state.doc.slice(blockRange.start, blockRange.end)
|
||||
if (!schema.nodes.detailsContent.contentMatch.matchFragment(slice.content)) {
|
||||
return false
|
||||
}
|
||||
const sliceContent = slice.toJSON()?.content || []
|
||||
|
||||
return chain()
|
||||
.insertContentAt({
|
||||
from: blockRange.start,
|
||||
to: blockRange.end,
|
||||
}, {
|
||||
type: this.name,
|
||||
attrs: {
|
||||
open: true,
|
||||
},
|
||||
content: [
|
||||
{ type: 'detailsSummary' },
|
||||
{ type: 'detailsContent', content: sliceContent },
|
||||
],
|
||||
})
|
||||
.setTextSelection(blockRange.start + 2)
|
||||
.run()
|
||||
},
|
||||
unsetDetails: () => ({ state, chain }) => {
|
||||
const { schema, selection } = state
|
||||
const details = detailsParentInfo(selection.$from, schema)
|
||||
if (!details) {
|
||||
return false
|
||||
}
|
||||
const detailsContent = detailsContentNode(details.node, schema)
|
||||
if (!detailsContent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const content = detailsContent.content.toJSON()
|
||||
const range = {
|
||||
from: details.pos,
|
||||
to: details.pos + details.node.nodeSize,
|
||||
}
|
||||
|
||||
return chain()
|
||||
.insertContentAt(range, content)
|
||||
.setTextSelection(details.pos + 1)
|
||||
.run()
|
||||
},
|
||||
toggleDetails: () => ({ commands, state }) => {
|
||||
if (!isNodeActive(state, this.name)) {
|
||||
return commands.setDetails()
|
||||
}
|
||||
return commands.unsetDetails()
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// If in detailsSummary: Make sure details is open and jump to content
|
||||
Enter: ({ editor }) => {
|
||||
const { state } = editor
|
||||
const { schema, selection } = state
|
||||
const { $from } = selection
|
||||
if ($from.parent.type !== schema.nodes.detailsSummary) {
|
||||
return false
|
||||
}
|
||||
|
||||
const details = detailsParentInfo($from, schema)
|
||||
if (!details.node.attrs.open) {
|
||||
editor.commands.updateAttributes('details', { open: true })
|
||||
}
|
||||
|
||||
const detailsContent = detailsContentNode(details.node, schema)
|
||||
if (!detailsContent) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if next node is detailsContent
|
||||
const detailsNode = state.doc.nodeAt($from.after())
|
||||
if (!detailsNode?.type === schema.nodes.detailsContent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const detailsContentPos = $from.after()
|
||||
return editor.commands.setTextSelection(detailsContentPos)
|
||||
},
|
||||
}
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
export default Details
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue-2'
|
||||
import DetailsContentView from './DetailsContent.vue'
|
||||
|
||||
const DetailsContent = Node.create({
|
||||
name: 'detailsContent',
|
||||
// TODO: don't allow nested details
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
selectable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: 'details-content' }), 0]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(DetailsContentView)
|
||||
},
|
||||
|
||||
toMarkdown: (state, node) => {
|
||||
state.renderContent(node)
|
||||
state.ensureNewLine()
|
||||
},
|
||||
})
|
||||
|
||||
export default DetailsContent
|
|
@ -0,0 +1,23 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NodeViewWrapper class="details-content" as="div">
|
||||
<NodeViewContent />
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'
|
||||
|
||||
export default {
|
||||
name: 'DetailsContent',
|
||||
|
||||
components: {
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
|
||||
const DetailsSummary = Node.create({
|
||||
name: 'detailsSummary',
|
||||
content: 'text*',
|
||||
defining: true,
|
||||
selectable: false,
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'summary',
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['summary', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
},
|
||||
|
||||
toMarkdown: (state, node) => {
|
||||
state.write('<summary>')
|
||||
state.renderInline(node)
|
||||
state.write('</summary>\n')
|
||||
},
|
||||
})
|
||||
|
||||
export default DetailsSummary
|
|
@ -0,0 +1,96 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NodeViewWrapper data-text-el="details"
|
||||
class="details"
|
||||
as="div">
|
||||
<NcButton type="tertiary" size="small">
|
||||
<template #icon>
|
||||
<TriangleSmallDownIcon :size="20"
|
||||
class="button-open"
|
||||
:class="{ 'open': isOpen }"
|
||||
@click="toggleOpen" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NodeViewContent :class="{ 'is-hidden': !isOpen }" />
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcButton } from '@nextcloud/vue'
|
||||
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'
|
||||
import TriangleSmallDownIcon from 'vue-material-design-icons/TriangleSmallDown.vue'
|
||||
|
||||
export default {
|
||||
name: 'DetailsView',
|
||||
|
||||
components: {
|
||||
NcButton,
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
TriangleSmallDownIcon,
|
||||
},
|
||||
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
updateAttributes: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isOpen() {
|
||||
return this.node.attrs.open
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleOpen() {
|
||||
this.updateAttributes({
|
||||
open: !this.isOpen,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div.details {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 4px;
|
||||
|
||||
border: 1px solid var(--color-border-dark) !important;
|
||||
border-radius: var(--border-radius-large);
|
||||
|
||||
:deep(summary) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
:deep(.details-content) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.button-open {
|
||||
transform: rotate(-90deg);
|
||||
|
||||
&.open {
|
||||
transform: rotate(0deg);
|
||||
transition: transform var(--animation-slow);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.details-content .paragraph-content:last-child) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -126,6 +126,16 @@ describe('Markdown though editor', () => {
|
|||
test('mentions', () => {
|
||||
expect(markdownThroughEditor('@[username](mention://user/id)')).toBe(' @[username](mention://user/id) ')
|
||||
})
|
||||
|
||||
test('details', () => {
|
||||
expect(markdownThroughEditor('<details>\n<summary>**summary**</summary>\n* list\n\n</details>\n'))
|
||||
.toBe('<details>\n<summary>**summary**</summary>\n* list\n\n</details>\n')
|
||||
})
|
||||
|
||||
test('nested details', () => {
|
||||
expect(markdownThroughEditor('<details>\n<summary>summary</summary>\n* list\n\n<details>\n<summary>summary</summary>\ncontent\n\n</details>\n\n</details>\n'))
|
||||
.toBe('<details>\n<summary>summary</summary>\n* list\n\n<details>\n<summary>summary</summary>\ncontent\n\n</details>\n\n</details>\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Markdown serializer from html', () => {
|
||||
|
@ -190,6 +200,11 @@ describe('Markdown serializer from html', () => {
|
|||
expect(markdownThroughEditorHtml('<span class="mention" data-label="username" data-type="user" data-id="id">username</span>')).toBe(' @[username](mention://user/id) ')
|
||||
expect(markdownThroughEditorHtml('<span class="mention" data-label="whitespace user" data-type="user" data-id="whitespace user">whitespace user</span>')).toBe(' @[whitespace user](mention://user/whitespace%20user) ')
|
||||
})
|
||||
|
||||
test('details', () => {
|
||||
expect(markdownThroughEditorHtml('<details><summary><strong>summary</strong></summary><pre>code</pre></details>'))
|
||||
.toBe('<details>\n<summary>**summary**</summary>\n```\ncode\n```\n\n</details>\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Trailing nodes', () => {
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import markdownit from '../../markdownit'
|
||||
import stripIndent from './stripIndent.js'
|
||||
|
||||
describe('Details extension', () => {
|
||||
it(`renders`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\ncontent\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary>summary</summary><p>content</p></details>`
|
||||
)
|
||||
})
|
||||
it(`renders with empty summary`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary></summary>\ncontent\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary></summary><p>content</p></details>`
|
||||
)
|
||||
})
|
||||
it(`renders with empty content`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary></summary>\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary></summary></details>`
|
||||
)
|
||||
})
|
||||
it(`renders with spaces`, () => {
|
||||
const rendered = markdownit.render(` <details> \n <summary>summary </summary> \n content \n </details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary>summary</summary><p>content</p></details>`
|
||||
)
|
||||
})
|
||||
it(`renders with marks in summary`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>**summary**</summary>\ncontent\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary><strong>summary</strong></summary><p>content</p></details>`
|
||||
)
|
||||
})
|
||||
it(`renders with marks in content`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\n**content**\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary>summary</summary><p><strong>content</strong></p></details>`
|
||||
)
|
||||
})
|
||||
it(`renders with block elements in content`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\nparagraph\n- one\n- two\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary>summary</summary><p>paragraph</p><ul data-bullet="-"><li>one</li><li>two</li></ul></details>`
|
||||
)
|
||||
})
|
||||
it(`renders nested details`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\n<details>\n<summary>nested summary</summary>\nnested content\n</details>\ncontent\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<details><summary>summary</summary><details><summary>nested summary</summary><p>nested content</p></details><p>content</p></details>`
|
||||
)
|
||||
})
|
||||
it(`does not render with missing linebreak after details open`, () => {
|
||||
const rendered = markdownit.render(`<details><summary>summary</summary>\ncontent\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<p><details><summary>summary</summary>content</details></p>`
|
||||
)
|
||||
})
|
||||
it(`does not render with missing linebreak after summary`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>summary</summary>content\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<p><details><summary>summary</summary>content</details></p>`
|
||||
)
|
||||
})
|
||||
it(`does not render with missing linebreak before details close`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\ncontent</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<p><details><summary>summary</summary>content</details></p>`
|
||||
)
|
||||
})
|
||||
it(`does not render without summary`, () => {
|
||||
const rendered = markdownit.render(`<details>\ncontent\n</details>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<p><details>content</details></p>`
|
||||
)
|
||||
})
|
||||
it(`does not render with missing closing tag`, () => {
|
||||
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\ncontent`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<p><details><summary>summary</summary>content</p>`
|
||||
)
|
||||
})
|
||||
it(`does not render with just summary`, () => {
|
||||
const rendered = markdownit.render(`<summary>summary</summary>`)
|
||||
expect(stripIndent(rendered)).toBe(
|
||||
`<p><summary>summary</summary></p>`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
Загрузка…
Ссылка в новой задаче