diff --git a/examples/scripts/index-demo.js b/examples/scripts/index-demo.js index 6e173b73..27279389 100644 --- a/examples/scripts/index-demo.js +++ b/examples/scripts/index-demo.js @@ -178,6 +178,10 @@ var basicConfig = { toolbarRight: ['fullScreen', '|'], bubble: ['bold', 'italic', 'underline', 'strikethrough', 'sub', 'sup', 'quote', 'ruby', '|', 'size', 'color'], // array or false sidebar: ['mobilePreview', 'copy', 'theme'], + toc: { + updateLocationHash: false, // 要不要更新URL的hash + defaultModel: 'full', // pure: 精简模式/缩略模式,只有一排小点; full: 完整模式,会展示所有标题 + }, customMenu: { customMenuAName: customMenuA, customMenuBName: customMenuB, diff --git a/src/Cherry.config.js b/src/Cherry.config.js index 9790d6a8..d06ed3bb 100644 --- a/src/Cherry.config.js +++ b/src/Cherry.config.js @@ -292,6 +292,11 @@ const defaultConfig = { sidebar: [], bubble: ['bold', 'italic', 'underline', 'strikethrough', 'sub', 'sup', 'quote', '|', 'size', 'color'], // array or false float: ['h1', 'h2', 'h3', '|', 'checklist', 'quote', 'table', 'code'], // array or false + toc: false, // 不展示悬浮目录 + // toc: { + // updateLocationHash: false, // 要不要更新URL的hash + // defaultModel: 'full', // pure: 精简模式/缩略模式,只有一排小点; full: 完整模式,会展示所有标题 + // }, // 快捷键配置,如果配置为空,则使用toolbar的配置 shortcutKey: { // 'Alt-1': 'header', diff --git a/src/Cherry.js b/src/Cherry.js index 088c0790..d40e6da3 100644 --- a/src/Cherry.js +++ b/src/Cherry.js @@ -21,6 +21,7 @@ import Bubble from './toolbars/Bubble'; import FloatMenu from './toolbars/FloatMenu'; import Toolbar from './toolbars/Toolbar'; import ToolbarRight from './toolbars/ToolbarRight'; +import Toc from './toolbars/Toc'; import { createElement } from './utils/dom'; import Sidebar from './toolbars/Sidebar'; import { customizer, getThemeFromLocal, changeTheme } from './utils/config'; @@ -191,6 +192,21 @@ export default class Cherry extends CherryStatic { if (this.options.autoScrollByHashAfterInit) { setTimeout(this.scrollByHash.bind(this)); } + // 强制进行一次渲染 + this.editText(null, this.editor.editor); + if (this.options.toolbars.toc !== false) { + this.createToc(); + } + } + + createToc() { + this.toc = new Toc({ + $cherry: this, + // @ts-ignore + updateLocationHash: this.options.toolbars.toc.updateLocationHash ?? true, + // @ts-ignore + defaultModel: this.options.toolbars.toc.defaultModel ?? 'pure', + }); } /** @@ -318,7 +334,7 @@ export default class Cherry extends CherryStatic { const headerList = []; const headerRegex = /(.+?)<\/h[0-6]>/g; str.replace(headerRegex, (match, level, id, text) => { - headerList.push({ level: +level, id, text }); + headerList.push({ level: +level, id, text: text.replace(//, '') }); return match; }); return headerList; diff --git a/src/core/hooks/Suggester.js b/src/core/hooks/Suggester.js index 3cc07a43..eb7d227a 100644 --- a/src/core/hooks/Suggester.js +++ b/src/core/hooks/Suggester.js @@ -101,7 +101,6 @@ export default class Suggester extends SyntaxBase { this.suggester = {}; const defaultSuggest = []; // 默认的唤醒关键字 - debugger; for (const suggesterKeyword of suggesterKeywords) { defaultSuggest.push({ keyword: suggesterKeyword, diff --git a/src/sass/ch-icon.scss b/src/sass/ch-icon.scss index 92a7125b..e9ce0d06 100644 --- a/src/sass/ch-icon.scss +++ b/src/sass/ch-icon.scss @@ -105,4 +105,6 @@ .ch-icon-justify:before { content: "\EA6C" } .ch-icon-justifyCenter:before { content: "\EA6D" } .ch-icon-justifyLeft:before { content: "\EA6E" } -.ch-icon-justifyRight:before { content: "\EA6F" } \ No newline at end of file +.ch-icon-justifyRight:before { content: "\EA6F" } +.ch-icon-chevronsLeft:before { content: "\EA70" } +.ch-icon-chevronsRight:before { content: "\EA71" } \ No newline at end of file diff --git a/src/sass/cherry.scss b/src/sass/cherry.scss index 4ac6598c..c2fa190f 100644 --- a/src/sass/cherry.scss +++ b/src/sass/cherry.scss @@ -772,6 +772,130 @@ cursor: pointer; } +.cherry-flex-toc { + z-index: 11; + position: absolute; + width: 160px; + height: calc(100% - 220px); + max-height: 600px; + right: 0; + top: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: #FFFFFF33; + margin-right: 8px; + box-sizing: border-box; + user-select: none; + box-shadow: 0px 5px 11px #33333333; + border-radius: 10px; + transition: all 0.3s; + &:hover { + background-color: #FFF; + width: 260px; + } + .cherry-toc-head { + border-bottom: 1px dashed #33333333; + padding: 5px; + .cherry-toc-title{ + font-size: 16px; + font-weight: bold; + padding-left: 5px; + } + .ch-icon-chevronsLeft { + display: none; + } + .ch-icon-chevronsRight, .ch-icon-chevronsLeft { + padding: 5px; + position: absolute; + right: 0; + top:0; + } + i { + cursor: pointer; + padding: 5px 5px 0; + &:hover { + color: #3582fb; + } + } + } + .cherry-toc-list { + overflow-y: auto; + height: calc(100% - 40px); + overflow-x: hidden; + width: 100%; + padding-bottom: 10px; + .cherry-toc-one-a { + display: block; + text-decoration: none; + color: #000; + border-left: 5px solid #33333333; + height: 28px; + line-height: 28px; + transition: all 0.3s; + padding-left: 10px; + overflow: hidden; + word-break: break-all; + text-overflow: ellipsis; + cursor: pointer; + &.current { + border-left-color: #3582fb; + color: #3582fb; + } + &:hover { + border-left-color: #3582fbaa; + color: #3582fbaa; + } + } + .cherry-toc-one-a__1 { + font-weight: bold; + } + .cherry-toc-one-a__2 { + padding-left: 20px; + } + .cherry-toc-one-a__3 { + padding-left: 40px; + } + .cherry-toc-one-a__4 { + padding-left: 60px; + } + .cherry-toc-one-a__5 { + padding-left: 80px; + } + } + &.cherry-flex-toc__pure { + width: 30px; + height: calc(100% - 200px); + max-height: 600px; + background: #FFFFFF00; + box-shadow: none; + border-radius: 0; + .cherry-toc-head { + height: 25px; + border-bottom: 1px dashed #33333300; + .cherry-toc-title { + display: none; + } + .ch-icon-chevronsRight { + display: none; + } + .ch-icon-chevronsLeft { + display: inline; + } + } + .cherry-toc-list { + padding-left: 7px; + .cherry-toc-one-a { + overflow: hidden; + width: 0; + margin-bottom: 3px; + height: 5px; + border-left-width: 18px; + } + } + } +} + /** 引入自带的主题 */ @import './themes/default.scss'; @import './themes/dark.scss'; diff --git a/src/sass/icons/uEA70-chevronsLeft.svg b/src/sass/icons/uEA70-chevronsLeft.svg new file mode 100644 index 00000000..c32e3983 --- /dev/null +++ b/src/sass/icons/uEA70-chevronsLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/sass/icons/uEA71-chevronsRight.svg b/src/sass/icons/uEA71-chevronsRight.svg new file mode 100644 index 00000000..f5068145 --- /dev/null +++ b/src/sass/icons/uEA71-chevronsRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/toolbars/Toc.js b/src/toolbars/Toc.js new file mode 100644 index 00000000..41d20eea --- /dev/null +++ b/src/toolbars/Toc.js @@ -0,0 +1,222 @@ +/** + * 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 { createElement } from '../utils/dom'; +import md5 from 'md5'; +/** + * 悬浮目录 + */ +export default class Toc { + constructor(options) { + this.$cherry = options.$cherry; + this.editor = options.$cherry.editor.editor; + this.tocStr = ''; + this.updateLocationHash = options.updateLocationHash ?? true; + this.defaultModel = options.defaultModel ?? 'full'; + this.init(); + } + + init() { + this.drawDom(); + this.timer = setTimeout(() => { + this.updateTocList(); + }, 300); + this.editor.on('change', (codemirror, evt) => { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.updateTocList(); + this.$switchModel(this.model); + }, 300); + }); + this.$switchModel(this.getModelFromLocalStorage()); + } + + getModelFromLocalStorage() { + if (typeof localStorage === 'undefined') { + return this.defaultModel; + } + return localStorage.getItem('cherry-toc-model') || this.defaultModel; + } + + setModelToLocalStorage(model) { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem('cherry-toc-model', model); + } + + drawDom() { + const tocDom = createElement('div', 'cherry-flex-toc cherry-flex-toc__pure'); + const tocHead = createElement('div', 'cherry-toc-head'); + const tocTitle = createElement('span', 'cherry-toc-title'); + tocTitle.append(this.$cherry.locale.toc); + const tocClose = createElement('i', 'ch-icon ch-icon-chevronsRight'); + const tocOpen = createElement('i', 'ch-icon ch-icon-chevronsLeft'); + this.tocClose = tocClose; + this.tocOpen = tocOpen; + tocHead.appendChild(tocTitle); + tocHead.appendChild(tocClose); + tocHead.appendChild(tocOpen); + tocDom.appendChild(tocHead); + const tocListDom = createElement('div', 'cherry-toc-list'); + this.tocListDom = tocListDom; + tocDom.appendChild(tocListDom); + this.tocDom = tocDom; + this.$cherry.wrapperDom.appendChild(tocDom); + this.bindClickEvent(); + } + + bindClickEvent() { + this.tocDom.addEventListener('click', (e) => { + const a = this.$getClosestNode(e.target, 'A'); + if (a === false) { + return; + } + if (/cherry-toc-one-a/.test(a.className)) { + const { id, index } = a.dataset; + if (this.$cherry.status.previewer === 'hide') { + // editorOnly模式下,需要定位到编辑区对应位置 + const searcher = this.$cherry.editor.editor.getSearchCursor( + /(?:^|\n)\n*((?:[ \t\u00a0]*#{1,6}).+?|(?:[ \t\u00a0]*.+)\n(?:[ \t\u00a0]*[=]+|[-]+))(?=$|\n)/g, + ); + for (let i = 0; i <= index; i++) { + searcher.findNext(); + } + const target = searcher.from(); + this.$cherry.editor.scrollToLineNum(target.line, target.line + 1, 0); + } else { + // 有预览的情况下,直接通过滚动预览区位置实现滚动到锚点 + const target = + this.$cherry.previewer.getDomContainer().querySelectorAll('h1,h2,h3,h4,h5,h6,h7,h8')[index] ?? false; + if (target !== false) { + target.scrollIntoView(); + } + } + if (this.updateLocationHash) { + location.href = id; + } + } + }); + this.tocClose.addEventListener('click', (e) => { + this.$switchModel('pure'); + this.setModelToLocalStorage('pure'); + }); + this.tocOpen.addEventListener('click', (e) => { + this.$switchModel('full'); + this.setModelToLocalStorage('full'); + }); + if (window) { + window.addEventListener('resize', () => { + this.$switchModel(this.model); + }); + } + this.editor.on('scroll', (codemirror, evt) => { + this.updateTocList(true); + }); + this.$cherry.previewer.getDomContainer().addEventListener('scroll', () => { + this.updateTocList(true); + }); + } + + $switchModel(model = 'pure') { + this.model = model; + const targetClassName = `cherry-flex-toc__${model}`; + if (!this.tocDom.classList.contains(targetClassName)) { + this.tocDom.classList.remove(`cherry-flex-toc__pure`); + this.tocDom.classList.remove(`cherry-flex-toc__full`); + this.tocDom.classList.add(targetClassName); + } + const list = this.tocListDom.querySelectorAll('.cherry-toc-one-a'); + if (list.length > 0) { + let targetHeight = 28; + if (model === 'pure') { + const { height } = this.tocListDom.getBoundingClientRect(); + const minHeight = Math.floor((height - list.length * 3) / list.length); + // eslint-disable-next-line no-nested-ternary + targetHeight = minHeight < 3 ? 3 : minHeight > 10 ? 10 : minHeight; + } + for (let i = 0; i < list.length; i++) { + // @ts-ignore + if (list[i].style.height !== `${targetHeight}px`) { + // @ts-ignore + list[i].style.height = `${targetHeight}px`; + } + } + } + } + + $getClosestNode(node, targetNodeName) { + if (node.tagName === targetNodeName) { + return node; + } + if (node.parentNode.tagName === 'BODY') { + return false; + } + return this.$getClosestNode(node.parentNode, targetNodeName); + } + + updateTocList(onlyScroll = false) { + if (onlyScroll === true) { + // do nothing + } else { + const tocList = this.$cherry.getToc(); + let tocStr = ''; + tocList.map((item) => { + tocStr += item.text; + return item; + }); + tocStr = md5(tocStr); + if (this.tocStr !== tocStr) { + this.tocStr = tocStr; + let tocHtml = ''; + let index = 0; + tocList.map((item) => { + const text = item.text.replace(//g, ''); + const title = text.replace(/<[^>]+?>/g, ''); + tocHtml += `${text}`; + index += 1; + return item; + }); + this.tocListDom.innerHTML = tocHtml; + } + } + // 处理当前标题的高亮 + if (this.$cherry.status.previewer === 'hide') { + // 似乎没有特别好的办法,先欠着 + } else { + const minY = this.$cherry.previewer.getDomContainer().getBoundingClientRect().y; + const headList = this.$cherry.previewer.getDomContainer().querySelectorAll('h1,h2,h3,h4,h5,h6,h7,h8'); + let index = 0; + for (; index < headList.length; index++) { + const { y } = headList[index].getBoundingClientRect(); + if (y > minY + 20) { + break; + } + } + index = index > 0 ? index - 1 : index; + this.tocListDom.querySelectorAll('.cherry-toc-one-a').forEach((item, key) => { + if (key === index) { + item.classList.add('current'); + } else { + item.classList.remove('current'); + } + }); + } + } +} diff --git a/types/cherry.d.ts b/types/cherry.d.ts index 9a85121a..71b4ba63 100644 --- a/types/cherry.d.ts +++ b/types/cherry.d.ts @@ -228,6 +228,11 @@ export interface CherryToolbarOptions { toolbarRight?: | (CherryCustomToolbar | CherryDefaultBubbleToolbar | CherryDefaultBubbleToolbar | CherryDefaultToolbar)[] | false; + /** 是否展示悬浮目录 */ + toc?: false | { + updateLocationHash: boolean, // 要不要更新URL的hash + defaultModel: 'pure' | 'full', // pure: 精简模式/缩略模式,只有一排小点; full: 完整模式,会展示所有标题 + }; /** 是否展示顶部工具栏 */ showToolbar?: boolean; /** 侧边栏配置 */