diff --git a/examples/markdown/basic.md b/examples/markdown/basic.md index 5a61c6ed9..631d811b0 100644 --- a/examples/markdown/basic.md +++ b/examples/markdown/basic.md @@ -310,6 +310,44 @@ ::: +--- + +## 手风琴 + +**说明** +使用连续三个加号`+++`和关键字(`[ + | - ]`)来声明,关键字`+`表示默认收起,关键字`-`表示默认展开 +``` +++++ +点击展开更多 +: +内容 ++- +默认展开 +: +内容 +++ +默认收起 +: +内容 ++++ +``` + +**效果** +++++ +点击展开更多 +: +内容 ++- +默认展开 +: +内容 +++ +默认收起 +: +内容 ++++ + + --- ## 语法高亮 diff --git a/examples/scripts/index-demo.js b/examples/scripts/index-demo.js index 6da04b8e6..8d83556d1 100644 --- a/examples/scripts/index-demo.js +++ b/examples/scripts/index-demo.js @@ -155,6 +155,7 @@ var basicConfig = { 'ul', 'checklist', 'panel', + 'detail', '|', 'formula', { diff --git a/src/Cherry.config.js b/src/Cherry.config.js index f2873c1b5..fb4a79c49 100644 --- a/src/Cherry.config.js +++ b/src/Cherry.config.js @@ -220,6 +220,7 @@ const defaultConfig = { '|', 'list', 'panel', + 'detail', { insert: [ 'image', diff --git a/src/Previewer.js b/src/Previewer.js index 8761086cf..7b5acb62e 100644 --- a/src/Previewer.js +++ b/src/Previewer.js @@ -496,11 +496,14 @@ export default class Previewer { continue; } } - if (/^(class|id|href|rel|target|src|title|controls|align|width|height|style)$/i.test(name)) { + if (/^(class|id|href|rel|target|src|title|controls|align|width|height|style|open)$/i.test(name)) { name = name === 'class' ? 'className' : name; if (name === 'style') { ret.style = ret.style ? ret.style : []; ret.style.push(value); + } else if (name === 'open') { + // 只要有open这个属性,就一定是true + ret[name] = true; } else { ret[name] = value; } diff --git a/src/core/HooksConfig.js b/src/core/HooksConfig.js index 32e562b41..3bd2c4184 100644 --- a/src/core/HooksConfig.js +++ b/src/core/HooksConfig.js @@ -48,6 +48,7 @@ import HighLight from './hooks/HighLight'; import Suggester from './hooks/Suggester'; import Ruby from './hooks/Ruby'; import Panel from './hooks/Panel'; +import Detail from './hooks/Detail'; /** * 引擎各语法的配置 * 主要决定支持哪些语法,以及各语法的执行顺序 @@ -71,6 +72,7 @@ const hooksConfig = [ Header, // 处理标题, 传入strict属性严格要求ATX风格标题#后带空格 Hr, List, + Detail, Panel, Paragraph, // 普通段落 diff --git a/src/core/hooks/Br.js b/src/core/hooks/Br.js index 7e2564bf7..54f2198f3 100644 --- a/src/core/hooks/Br.js +++ b/src/core/hooks/Br.js @@ -35,7 +35,7 @@ export default class Br extends ParagraphBase { if (index === 0) { return match; } - const lineCount = lines.match(/\n/g).length; + const lineCount = lines.match(/\n/g)?.length ?? 0; const sign = `br${lineCount}`; let html = ''; if (isBrowser()) { @@ -49,7 +49,7 @@ export default class Br extends ParagraphBase { // node环境下直接输出br html = this.classicBr ? '' : '
'; } - const placeHolder = this.pushCache(html, sign); + const placeHolder = this.pushCache(html, sign, lineCount); // 结尾只补充一个\n是因为Br将下一个段落中间的所有换行都替换掉了,而两个换行符会导致下一个区块行数计算错误 return `\n\n${placeHolder}\n`; }); diff --git a/src/core/hooks/Detail.js b/src/core/hooks/Detail.js new file mode 100644 index 000000000..4a34e1fce --- /dev/null +++ b/src/core/hooks/Detail.js @@ -0,0 +1,110 @@ +/** + * Copyright (C) 2021 THL A29 Limited, a Tencent company. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import ParagraphBase from '@/core/ParagraphBase'; +import { prependLineFeedForParagraph } from '@/utils/lineFeed'; +import { getDetailRule } from '@/utils/regexp'; +import { blockNames } from '@/utils/sanitize'; + +/** + * +++(+|-) + * 点击查看详情 + * : + * body + * body + * +- + * 标题(默认收起内容) + * : + * 内容 + * ++ + * 标题(默认展开内容) + * : + * 内容2 + * +++ + */ +export default class Detail extends ParagraphBase { + static HOOK_NAME = 'detail'; + + constructor() { + super({ needCache: true }); + } + + makeHtml(str, sentenceMakeFunc) { + return str.replace(this.RULE.reg, (match, preLines, isShow, content) => { + const lineCount = this.getLineCount(match, preLines); + const sign = this.$engine.md5(match); + const { type, html } = this.$getDetailInfo(isShow, content, sentenceMakeFunc); + const ret = this.pushCache( + `
${html}
`, + sign, + lineCount, + ); + return prependLineFeedForParagraph(match, ret); + }); + } + + $getDetailInfo(isShow, str, sentenceMakeFunc) { + const type = /\n\s*(\+\+|\+-)\s*\n/.test(str) ? 'multiple' : 'single'; + const arr = str.split(/\n\s*(\+\+|\+-)\s*\n/); + let defaultShow = isShow !== '+'; + let html = ''; + if (type === 'multiple') { + arr.forEach((item) => { + if (/(\+\+|\+-)/.test(item)) { + defaultShow = item !== '++'; + return true; + } + html += this.$getDetailHtml(defaultShow, item, sentenceMakeFunc); + }); + } else { + html = this.$getDetailHtml(defaultShow, str, sentenceMakeFunc); + } + return { type, html }; + } + + $getDetailHtml(defaultShow, str, sentenceMakeFunc) { + let ret = `
`; + const paragraphProcessor = (str) => { + if (str.trim() === '') { + return ''; + } + // 调用行内语法,获得段落的签名和对应html内容 + const { html } = sentenceMakeFunc(str); + let domName = 'p'; + // 如果包含html块级标签(比如div、blockquote等),则当前段落外层用div包裹,反之用p包裹 + const isContainBlockTest = new RegExp(`<(${blockNames})[^>]*>`, 'i'); + if (isContainBlockTest.test(html)) { + domName = 'div'; + } + return `<${domName}>${this.$cleanParagraph(html)}`; + }; + str.replace(/(^[\w\W]+?)\n\s*:\s*\n([\w\W]+$)/, (match, title, body) => { + ret += `${sentenceMakeFunc(title).html}`; + let $body = ''; + if (this.isContainsCache(body)) { + $body = this.makeExcludingCached(body, paragraphProcessor); + } else { + $body = paragraphProcessor(body); + } + ret += `
${$body}
`; + }); + ret += `
`; + return ret; + } + + rule() { + return getDetailRule(); + } +} diff --git a/src/core/hooks/List.js b/src/core/hooks/List.js index e7e994860..c93642626 100644 --- a/src/core/hooks/List.js +++ b/src/core/hooks/List.js @@ -224,14 +224,23 @@ export default class List extends ParagraphBase { const text = wholeMatch.replace(/~0$/g, '').replace(/^\n+/, ''); this.buildTree(makeChecklist(text), sentenceMakeFunc); const result = this.renderTree(0); - return this.pushCache(result, this.sign); + return this.pushCache(result, this.sign, this.$getLineNum(wholeMatch)); + } + + $getLineNum(str) { + const beginLine = str.match(/^\n\n/)?.length ?? 0; + const $str = str.replace(/^\n+/, '').replace(/\n+$/, '\n'); + return $str.match(/\n/g)?.length ?? 0 + beginLine; } makeHtml(str, sentenceMakeFunc) { let $str = `${str}~0`; if (this.test($str)) { $str = $str.replace(this.RULE.reg, (wholeMatch) => { - return this.getCacheWithSpace(this.checkCache(wholeMatch, sentenceMakeFunc), wholeMatch); + return this.getCacheWithSpace( + this.checkCache(wholeMatch, sentenceMakeFunc, this.$getLineNum(wholeMatch)), + wholeMatch, + ); }); } $str = $str.replace(/~0$/g, ''); diff --git a/src/core/hooks/Panel.js b/src/core/hooks/Panel.js index bb24ff9b0..d9b82e6e6 100644 --- a/src/core/hooks/Panel.js +++ b/src/core/hooks/Panel.js @@ -16,6 +16,7 @@ import ParagraphBase from '@/core/ParagraphBase'; import { prependLineFeedForParagraph } from '@/utils/lineFeed'; import { getPanelRule } from '@/utils/regexp'; +import { blockNames } from '@/utils/sanitize'; /** * 面板语法 * 例: @@ -60,7 +61,27 @@ export default class Panel extends ParagraphBase { ret.title = `
${ ret.title }
`; - ret.body = `
${this.$cleanParagraph(sentenceMakeFunc(ret.body).html)}
`; + const paragraphProcessor = (str) => { + if (str.trim() === '') { + return ''; + } + // 调用行内语法,获得段落的签名和对应html内容 + const { html } = sentenceMakeFunc(str); + let domName = 'p'; + // 如果包含html块级标签(比如div、blockquote等),则当前段落外层用div包裹,反之用p包裹 + const isContainBlockTest = new RegExp(`<(${blockNames})[^>]*>`, 'i'); + if (isContainBlockTest.test(html)) { + domName = 'div'; + } + return `<${domName}>${this.$cleanParagraph(html)}`; + }; + let $body = ''; + if (this.isContainsCache(ret.body)) { + $body = this.makeExcludingCached(ret.body, paragraphProcessor); + } else { + $body = paragraphProcessor(ret.body); + } + ret.body = `
${$body}
`; return ret; } diff --git a/src/locales/zh_CN.js b/src/locales/zh_CN.js index e8bd016f3..f2a88afff 100644 --- a/src/locales/zh_CN.js +++ b/src/locales/zh_CN.js @@ -79,6 +79,7 @@ export default { hide: '隐藏(ctrl+0)', // 隐藏(ctrl+0) exportToPdf: '导出PDF', // 导出PDF exportScreenshot: '导出长图', // 导出长图 - theme: '主题', // 导出长图 - panel: '面板', // 导出长图 + theme: '主题', // 主题 + panel: '面板', // 面板 + detail: '手风琴', // 手风琴 }; diff --git a/src/sass/markdown.scss b/src/sass/markdown.scss index ab870711e..4f0711733 100644 --- a/src/sass/markdown.scss +++ b/src/sass/markdown.scss @@ -471,6 +471,38 @@ div[data-type='codeBlock'] { } } +.cherry-detail { + details { + background: #f8f9faaa; + border-radius: 8px; + overflow: hidden; + margin-bottom: 10px; + summary { + user-select: none; + padding: 5px 10px; + background-color: #6c757d; + color: #FFF; + border-radius: 8px; + } + .cherry-detail-body { + padding: 15px 25px 0 25px; + } + } +} + +.cherry-detail__multiple { + border-radius: 8px; + overflow: hidden; + details { + margin-bottom: 1px; + border-radius: 0; + border: none; + summary { + border-radius: 0; + } + } +} + .cherry-panel { margin: 10px 0; overflow: hidden; diff --git a/src/toolbars/HookCenter.js b/src/toolbars/HookCenter.js index cfc8ee8c3..507849605 100644 --- a/src/toolbars/HookCenter.js +++ b/src/toolbars/HookCenter.js @@ -66,6 +66,7 @@ import Theme from './hooks/Theme'; import MobilePreview from './hooks/MobilePreview'; import Copy from './hooks/Copy'; import Panel from './hooks/Panel'; +import Detail from './hooks/Detail'; // 定义默认支持的工具栏 // 目前不支持按需动态加载 @@ -120,6 +121,7 @@ const HookList = { theme: Theme, file: File, panel: Panel, + detail: Detail, }; export default class HookCenter { diff --git a/src/toolbars/hooks/Detail.js b/src/toolbars/hooks/Detail.js new file mode 100644 index 000000000..447fa03b4 --- /dev/null +++ b/src/toolbars/hooks/Detail.js @@ -0,0 +1,64 @@ +/** + * Copyright (C) 2021 THL A29 Limited, a Tencent company. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import MenuBase from '@/toolbars/MenuBase'; +import { getDetailRule } from '@/utils/regexp'; +import { getSelection } from '@/utils/selection'; +/** + * 插入手风琴 + */ +export default class Detail extends MenuBase { + constructor($cherry) { + super($cherry); + this.setName('detail', 'insertFlow'); + this.detailRule = getDetailRule().reg; + } + + /** + * 响应点击事件 + * @param {string} selection 被用户选中的文本内容 + * @returns {string} 回填到编辑器光标位置/选中文本区域的内容 + */ + onClick(selection) { + let $selection = + getSelection(this.editor.editor, selection, 'line', true) || + '点击展开更多\n:\n内容\n+-\n默认展开\n:\n内容\n++\n默认收起\n:\n内容'; + this.detailRule.lastIndex = 0; + if (!this.detailRule.test($selection)) { + // 如果没有命中手风琴语法,则尝试扩大选区 + this.getMoreSelection('\n', '\n', () => { + const newSelection = this.editor.editor.getSelection(); + this.detailRule.lastIndex = 0; + const isMatch = this.detailRule.test(newSelection); + if (isMatch !== false) { + $selection = newSelection; + } + return isMatch !== false; + }); + } + this.detailRule.lastIndex = 0; + if (this.detailRule.test($selection)) { + // 如果命中了手风琴语法,则去掉手风琴语法 + this.detailRule.lastIndex = 0; + return $selection.replace(this.detailRule, (match, preLines, isShow, content) => { + return content; + }); + } + this.registerAfterClickCb(() => { + this.setLessSelection('\n', '\n'); + }); + return `++++\n${$selection}\n+++`.replace(/\n{2,}\+\+\+/g, '\n+++'); + } +} diff --git a/src/toolbars/hooks/Panel.js b/src/toolbars/hooks/Panel.js index fef86d2b1..33cac4c36 100644 --- a/src/toolbars/hooks/Panel.js +++ b/src/toolbars/hooks/Panel.js @@ -60,6 +60,7 @@ export default class Panel extends MenuBase { */ $getNameFromStr(str) { let ret = false; + this.panelRule.lastIndex = 0; str.replace(this.panelRule, (match, preLines, name, content) => { ret = name ? name : ''; return match; @@ -92,6 +93,7 @@ export default class Panel extends MenuBase { // 如果命中了面板语法,则尝试去掉语法或者变更语法 if (currentName === shortKey) { // 去掉面板语法 + this.panelRule.lastIndex = 0; return $selection.replace(this.panelRule, (match, preLines, name, content) => { return content; }); @@ -100,8 +102,9 @@ export default class Panel extends MenuBase { this.registerAfterClickCb(() => { this.setLessSelection('\n', '\n'); }); + this.panelRule.lastIndex = 0; return $selection.replace(this.panelRule, (match, preLines, name, content) => { - return `:::${shortKey}\n${content}:::`; + return `:::${shortKey}\n${content.replace(/\n+$/, '')}\n:::`; }); } this.registerAfterClickCb(() => { diff --git a/src/utils/regexp.js b/src/utils/regexp.js index 837f0ddce..9de2a1df4 100644 --- a/src/utils/regexp.js +++ b/src/utils/regexp.js @@ -213,7 +213,36 @@ export function getPanelRule() { const ret = { begin: /(?:^|\n)(\n*(?:[^\S\n]*)):::([^:\n\s]+?)\s*\n/, content: /([\w\W]*?)/, - end: /[^\S\n]*:::[ \t]*(?=$|\n+)/, + end: /\n[ \t]*:::[ \t]*(?=$|\n+)/, + }; + ret.reg = new RegExp(ret.begin.source + ret.content.source + ret.end.source, 'g'); + return ret; +} + +/** + * 手风琴/detail语法的识别正则 + * 例: + * +++(+|-) + * 点击查看详情 + * : + * body + * body + * +- + * 标题(默认收起内容) + * : + * 内容 + * ++ + * 标题(默认展开内容) + * : + * 内容2 + * +++ + * @returns {object} + */ +export function getDetailRule() { + const ret = { + begin: /(?:^|\n)(\n*(?:[^\S\n]*))\+\+\+(\+|-)\s*\n/, + content: /([\w\W]+?\n:\n[\w\W]+?)/, + end: /\n[ \t]*\+\+\+[ \t]*(?=$|\n+)/, }; ret.reg = new RegExp(ret.begin.source + ret.content.source + ret.end.source, 'g'); return ret; diff --git a/vscodePlugin/src/readme.md b/vscodePlugin/src/readme.md new file mode 100644 index 000000000..772bc5006 --- /dev/null +++ b/vscodePlugin/src/readme.md @@ -0,0 +1,4 @@ +## 发布插件 +``` +vsce publish +```