Liyuan Li 5 år sedan
förälder
incheckning
09b3c9ad50

+ 3 - 1
CHANGELOG.md

@@ -59,6 +59,7 @@
 
 ### v3.1.7 / 2020-04-0x
 
+* [94](https://github.com/Vanessa219/vditor/issues/94) 获取大纲内容及同步滚动效果 `引入特性`
 
 ### v3.1.6 / 2020-04-12
 
@@ -86,11 +87,12 @@
 * [278](https://github.com/Vanessa219/vditor/issues/278) IR 细节修改 `修复缺陷`
 * 文档更新
   * 添加 `options.minHeight`
-  * 为 `options.toolbar` 添加 outdent,indent
   * `options.counter` 修改为 `counter?: { enable: boolean; max?: number; type: "markdown" | "text"; }`
   * counter 位置移动到 toolbar 上
   * `options.hideToolbar` 修改为 `toolbarConfig: { hide?: boolean, pin?: boolean }`
   * 添加 `options.upload.setHeaders: { [key: string]: string }`
+  * 为 `options.toolbar` 添加 outdent,indent, outline
+  * 添加静态方法 `outlineRender`
 
 ### v3.0.12 / 2020-04-06
 

+ 7 - 0
src/assets/scss/_content.scss

@@ -233,6 +233,13 @@
     left: 5px;
     border-bottom-color: var(--panel-background-color);
   }
+
+  &-outline {
+    width: 250px;
+    border-right: 1px solid var(--border-color);
+    background-color: var(--textarea-background-color);
+    display: none;
+  }
 }
 
 @media screen and (max-width: $max-width) {

+ 3 - 0
src/method.ts

@@ -8,6 +8,7 @@ import {mediaRender} from "./ts/markdown/mediaRender";
 import {mermaidRender} from "./ts/markdown/mermaidRender";
 import {md2html, previewRender} from "./ts/markdown/previewRender";
 import {speechRender} from "./ts/markdown/speechRender";
+import {outlineRender} from "./ts/markdown/outlineRender";
 class Vditor {
 
     /** 为 element 中的代码块添加复制按钮 */
@@ -24,6 +25,8 @@ class Vditor {
     public static chartRender = chartRender;
     /** 五线谱渲染 */
     public static abcRender = abcRender;
+    /** 大纲渲染 */
+    public static outlineRender = outlineRender;
     /** 为[特定链接](https://github.com/Vanessa219/vditor/issues/7)分别渲染为视频、音频、嵌入的 iframe */
     public static mediaRender = mediaRender;
     /** 对选中的文字进行阅读 */

+ 3 - 0
src/ts/i18n/index.ts

@@ -38,6 +38,7 @@ export const i18n: II18n = {
         "nameEmpty": "Name is empty",
         "ordered-list": "Order List",
         "outdent": "Outdent",
+        "outline": "Outline",
         "over": "over",
         "performanceTip": "Real-time preview requires ${x}ms, you can close it",
         "preview": "Preview",
@@ -99,6 +100,7 @@ export const i18n: II18n = {
         "nameEmpty": "이름이 비어있습니다.",
         "ordered-list": "번호매기기",
         "outdent": "내어쓰기",
+        "outline": "개요",
         "over": "오버",
         "performanceTip": "실시간 미리보기에는 ${x}ms가 필요하며 에디터/미리보기 버튼을 클릭하여 닫을 수 있습니다.",
         "preview": "미리보기",
@@ -160,6 +162,7 @@ export const i18n: II18n = {
         "nameEmpty": "文件名不能为空",
         "ordered-list": "有序列表",
         "outdent": "减少缩进",
+        "outline": "大纲",
         "over": "超过",
         "performanceTip": "实时预览需 ${x}ms,可点击编辑 & 预览按钮进行关闭",
         "preview": "预览",

+ 2 - 1
src/ts/ir/highlightToolbar.ts

@@ -1,7 +1,8 @@
 import {Constants} from "../constants";
 import {disableToolbar, enableToolbar, removeCurrentToolbar, setCurrentToolbar} from "../toolbar/setToolbar";
-import {hasClosestByAttribute, hasClosestByHeadings, hasClosestByMatchTag} from "../util/hasClosest";
+import {hasClosestByAttribute, hasClosestByMatchTag} from "../util/hasClosest";
 import {getEditorRange, selectIsEditor} from "../util/selection";
+import {hasClosestByHeadings} from "../util/hasClosestByHEadings";
 
 export const highlightToolbar = (vditor: IVditor) => {
     clearTimeout(vditor.ir.hlToolbarTimeoutId);

+ 2 - 2
src/ts/ir/input.ts

@@ -3,12 +3,12 @@ import {
     getTopList,
     hasClosestBlock, hasClosestByAttribute,
     hasClosestByClassName,
-    hasClosestByTag,
 } from "../util/hasClosest";
 import {log} from "../util/log";
 import {processCodeRender} from "../util/processCode";
 import {getSelectPosition, setRangeByWbr} from "../util/selection";
 import {processAfterRender} from "./process";
+import {hasClosestByTag} from "../util/hasClosestByHEadings";
 
 export const input = (vditor: IVditor, range: Range) => {
     let blockElement = hasClosestBlock(range.startContainer);
@@ -184,7 +184,7 @@ export const input = (vditor: IVditor, range: Range) => {
         processCodeRender(item, vditor);
     });
 
-    renderToc(vditor.ir.element);
+    renderToc(vditor);
 
     processAfterRender(vditor, {
         enableAddUndoStack: true,

+ 1 - 1
src/ts/ir/process.ts

@@ -85,7 +85,7 @@ export const processHeading = (vditor: IVditor, value: string) => {
             document.execCommand("insertHTML", false, value);
         }
         highlightToolbar(vditor);
-        renderToc(vditor.ir.element);
+        renderToc(vditor);
     }
 };
 

+ 1 - 1
src/ts/ir/processKeydown.ts

@@ -13,10 +13,10 @@ import {
 import {
     hasClosestByAttribute,
     hasClosestByClassName,
-    hasClosestByHeadings,
     hasClosestByMatchTag,
 } from "../util/hasClosest";
 import {getEditorRange, getSelectPosition} from "../util/selection";
+import {hasClosestByHeadings} from "../util/hasClosestByHEadings";
 
 export const processKeydown = (vditor: IVditor, event: KeyboardEvent) => {
     vditor.ir.composingLock = event.isComposing;

+ 18 - 0
src/ts/markdown/outlineRender.ts

@@ -0,0 +1,18 @@
+import {hasClosestByHeadings} from "../util/hasClosestByHEadings";
+
+export const outlineRender = (contentElement: HTMLElement, targetElement: HTMLElement) => {
+    let tocHTML = "";
+    const isIR = contentElement.parentElement.classList.contains("vditor-ir");
+    Array.from(contentElement.children).forEach((item: HTMLElement) => {
+        if (hasClosestByHeadings(item)) {
+            const headingNo = parseInt(item.tagName.substring(1), 10);
+            const space = new Array((headingNo - 1) * 2).fill(" ").join("");
+            if (isIR) {
+                tocHTML += `${space}<span data-type="toc-h">${item.textContent.substring(headingNo + 1).trim()}</span><br>`;
+            } else {
+                tocHTML += `${space}<span data-type="toc-h">${item.textContent.trim()}</span><br>`;
+            }
+        }
+    });
+    targetElement.innerHTML = tocHTML || "[ToC]";
+};

+ 20 - 3
src/ts/toolbar/EditMode.ts

@@ -3,6 +3,7 @@ import {Constants} from "../constants";
 import {i18n} from "../i18n";
 import {highlightToolbar as IRHighlightToolbar} from "../ir/highlightToolbar";
 import {processAfterRender} from "../ir/process";
+import {outlineRender} from "../markdown/outlineRender";
 import {formatRender} from "../sv/formatRender";
 import {setPadding, setTypewriterPosition} from "../ui/initUI";
 import {getEventName, updateHotkeyTip} from "../util/compatibility";
@@ -35,13 +36,14 @@ export const setEditMode = (vditor: IVditor, type: string, event: Event | string
             vditor.preview.element.style.display = "none";
         }
     }
+
     enableToolbar(vditor.toolbar.elements, Constants.TOOLBARS);
     removeCurrentToolbar(vditor.toolbar.elements, Constants.TOOLBARS);
     disableToolbar(vditor.toolbar.elements, ["outdent", "indent"]);
 
     if (type === "ir") {
         hideToolbar(vditor.toolbar.elements, ["format", "both", "preview"]);
-        showToolbar(vditor.toolbar.elements, ["outdent", "indent"]);
+        showToolbar(vditor.toolbar.elements, ["outdent", "indent", "outline"]);
         vditor.irUndo.resetIcon(vditor);
         vditor.sv.element.style.display = "none";
         vditor.wysiwyg.element.parentElement.style.display = "none";
@@ -60,6 +62,12 @@ export const setEditMode = (vditor: IVditor, type: string, event: Event | string
             vditor.ir.element.focus();
             IRHighlightToolbar(vditor);
         }
+
+        if (vditor.toolbar.elements.outline && vditor.toolbar.elements.outline.firstElementChild.classList.contains("vditor-menu--current")) {
+            vditor.element.querySelector(".vditor-outline").setAttribute("style", "display:block");
+            outlineRender(vditor.ir.element, vditor.element.querySelector(".vditor-outline"));
+        }
+
         setPadding(vditor);
 
         vditor.ir.element.querySelectorAll(".vditor-ir__preview[data-render='2']").forEach((item: HTMLElement) => {
@@ -67,13 +75,19 @@ export const setEditMode = (vditor: IVditor, type: string, event: Event | string
         });
     } else if (type === "wysiwyg") {
         hideToolbar(vditor.toolbar.elements, ["format", "both", "preview"]);
-        showToolbar(vditor.toolbar.elements, ["outdent", "indent"]);
+        showToolbar(vditor.toolbar.elements, ["outdent", "indent", "outline"]);
         vditor.wysiwygUndo.resetIcon(vditor);
         vditor.sv.element.style.display = "none";
         vditor.wysiwyg.element.parentElement.style.display = "block";
         vditor.ir.element.parentElement.style.display = "none";
 
         vditor.currentMode = "wysiwyg";
+
+        if (vditor.toolbar.elements.outline && vditor.toolbar.elements.outline.firstElementChild.classList.contains("vditor-menu--current")) {
+            vditor.element.querySelector(".vditor-outline").setAttribute("style", "display:block");
+            outlineRender(vditor.wysiwyg.element, vditor.element.querySelector(".vditor-outline"));
+        }
+
         setPadding(vditor);
         renderDomByMd(vditor, markdownText, false);
 
@@ -85,7 +99,7 @@ export const setEditMode = (vditor: IVditor, type: string, event: Event | string
         vditor.wysiwyg.popover.style.display = "none";
     } else if (type === "sv") {
         showToolbar(vditor.toolbar.elements, ["format", "both", "preview"]);
-        hideToolbar(vditor.toolbar.elements, ["outdent", "indent"]);
+        hideToolbar(vditor.toolbar.elements, ["outdent", "indent", "outline"]);
         vditor.undo.resetIcon(vditor);
         vditor.wysiwyg.element.parentElement.style.display = "none";
         vditor.ir.element.parentElement.style.display = "none";
@@ -102,6 +116,9 @@ export const setEditMode = (vditor: IVditor, type: string, event: Event | string
             enableHint: false,
             enableInput: false,
         });
+        if (vditor.toolbar.elements.outline && type === "sv") {
+            vditor.element.querySelector(".vditor-outline").setAttribute("style", "display:none");
+        }
         if (typeof event !== "string") {
             // 初始化不 focus
             vditor.sv.element.focus();

+ 31 - 0
src/ts/toolbar/Outline.ts

@@ -0,0 +1,31 @@
+import alignCenterSVG from "../../assets/icons/align-center.svg";
+import {Constants} from "../constants";
+import {outlineRender} from "../markdown/outlineRender";
+import {setPadding} from "../ui/initUI";
+import {getEventName} from "../util/compatibility";
+import {MenuItem} from "./MenuItem";
+
+export class Outline extends MenuItem {
+    constructor(vditor: IVditor, menuItem: IMenuItem) {
+        super(vditor, menuItem);
+        this.element.children[0].innerHTML = menuItem.icon || alignCenterSVG;
+        this.element.children[0].addEventListener(getEventName(), (event) => {
+            const btnElement = this.element.firstElementChild;
+            if (btnElement.classList.contains(Constants.CLASS_MENU_DISABLED)) {
+                return;
+            }
+
+            const outlineElement = vditor.element.querySelector(".vditor-outline") as HTMLElement;
+            if (btnElement.classList.contains("vditor-menu--current")) {
+                outlineElement.style.display = "none";
+                btnElement.classList.remove("vditor-menu--current");
+            } else {
+                outlineRender(vditor[vditor.currentMode].element, vditor.element.querySelector(".vditor-outline"));
+                outlineElement.style.display = "block";
+                btnElement.classList.add("vditor-menu--current");
+            }
+            setPadding(vditor);
+            event.preventDefault();
+        });
+    }
+}

+ 4 - 0
src/ts/toolbar/index.ts

@@ -22,6 +22,7 @@ import {Link} from "./Link";
 import {List} from "./List";
 import {OrderedList} from "./OrderedList";
 import {Outdent} from "./Outdent";
+import {Outline} from "./Outline";
 import {Preview} from "./Preview";
 import {Quote} from "./Quote";
 import {Record} from "./Record";
@@ -138,6 +139,9 @@ export class Toolbar {
                 case "indent":
                     menuItemObj = new Indent(vditor, menuItem);
                     break;
+                case "outline":
+                    menuItemObj = new Outline(vditor, menuItem);
+                    break;
                 default:
                     menuItemObj = new Custom(vditor, menuItem);
                     break;

+ 6 - 0
src/ts/ui/initUI.ts

@@ -23,6 +23,12 @@ export const initUI = (vditor: IVditor) => {
     const contentElement = document.createElement("div");
     contentElement.className = "vditor-content";
 
+    if (vditor.toolbar.elements.outline) {
+        const outlineElement = document.createElement("div");
+        outlineElement.className = "vditor-outline";
+        contentElement.appendChild(outlineElement);
+    }
+
     contentElement.appendChild(vditor.wysiwyg.element.parentElement);
 
     contentElement.appendChild(vditor.sv.element);

+ 3 - 0
src/ts/util/Options.ts

@@ -197,6 +197,9 @@ export class Options {
             hotkey: "⌘-'",
             name: "fullscreen",
             tipPosition: "nw",
+        }, {
+            name: "outline",
+            tipPosition: "nw",
         }, {
             name: "devtools",
             tipPosition: "nw",

+ 8 - 4
src/ts/util/fixBrowserBehavior.ts

@@ -1,5 +1,6 @@
 import {Constants} from "../constants";
 import {processAfterRender} from "../ir/process";
+import {outlineRender} from "../markdown/outlineRender";
 import {uploadFiles} from "../upload";
 import {setHeaders} from "../upload/setHeaders";
 import {processCodeRender, processPasteCode} from "../util/processCode";
@@ -11,12 +12,13 @@ import {
     getTopList,
     hasClosestBlock,
     hasClosestByAttribute,
-    hasClosestByClassName, hasClosestByHeadings,
+    hasClosestByClassName,
     hasClosestByMatchTag,
 } from "./hasClosest";
 import {getLastNode} from "./hasClosest";
 import {matchHotKey} from "./hotKey";
 import {getSelectPosition, insertHTML, setRangeByWbr, setSelectionByPosition} from "./selection";
+import {hasClosestByHeadings} from "./hasClosestByHEadings";
 
 export const isFirstCell = (cellElement: HTMLElement) => {
     const tableElement = hasClosestByMatchTag(cellElement, "TABLE") as HTMLTableElement;
@@ -337,18 +339,18 @@ export const isToC = (text: string) => {
     return text.trim().toLowerCase() === "[toc]";
 };
 
-export const renderToc = (editorElement: HTMLPreElement) => {
+export const renderToc = (vditor: IVditor) => {
+    const editorElement = vditor[vditor.currentMode].element;
     const tocElement = editorElement.querySelector('[data-type="toc-block"]');
     if (!tocElement) {
         return;
     }
     let tocHTML = "";
-    const isIR = editorElement.parentElement.classList.contains("vditor-ir");
     Array.from(editorElement.children).forEach((item: HTMLElement) => {
         if (hasClosestByHeadings(item)) {
             const headingNo = parseInt(item.tagName.substring(1), 10);
             const space = new Array((headingNo - 1) * 2).fill("&emsp;").join("");
-            if (isIR) {
+            if (vditor.currentMode === "ir") {
                 tocHTML += `${space}<span data-type="toc-h">${item.textContent.substring(headingNo + 1).trim()}</span><br>`;
             } else {
                 tocHTML += `${space}<span data-type="toc-h">${item.textContent.trim()}</span><br>`;
@@ -356,6 +358,8 @@ export const renderToc = (editorElement: HTMLPreElement) => {
         }
     });
     tocElement.innerHTML = tocHTML || "[ToC]";
+
+    outlineRender(editorElement, vditor.element.querySelector(".vditor-outline"));
 };
 
 export const execAfterRender = (vditor: IVditor) => {

+ 1 - 26
src/ts/util/hasClosest.ts

@@ -1,29 +1,4 @@
-export const hasClosestByHeadings = (element: Node) => {
-    const headingElement = hasClosestByTag(element, "H");
-    if (headingElement && headingElement.tagName.length === 2 && headingElement.tagName !== "HR") {
-       return headingElement;
-    }
-    return false;
-};
-
-export const hasClosestByTag = (element: Node, nodeName: string) => {
-    if (!element) {
-        return false;
-    }
-    if (element.nodeType === 3) {
-        element = element.parentElement;
-    }
-    let e = element as HTMLElement;
-    let isClosest = false;
-    while (e && !isClosest && !e.classList.contains("vditor-reset")) {
-        if (e.nodeName.indexOf(nodeName) === 0) {
-            isClosest = true;
-        } else {
-            e = e.parentElement;
-        }
-    }
-    return isClosest && e;
-};
+import {hasClosestByTag} from "./hasClosestByHEadings";
 
 export const hasTopClosestByClassName = (element: Node, className: string) => {
     let closest = hasClosestByClassName(element, className);

+ 27 - 0
src/ts/util/hasClosestByHEadings.ts

@@ -0,0 +1,27 @@
+// NOTE: 减少 method.ts 打包,故从 hasClosest.ts 中拆分
+export const hasClosestByTag = (element: Node, nodeName: string) => {
+    if (!element) {
+        return false;
+    }
+    if (element.nodeType === 3) {
+        element = element.parentElement;
+    }
+    let e = element as HTMLElement;
+    let isClosest = false;
+    while (e && !isClosest && !e.classList.contains("vditor-reset")) {
+        if (e.nodeName.indexOf(nodeName) === 0) {
+            isClosest = true;
+        } else {
+            e = e.parentElement;
+        }
+    }
+    return isClosest && e;
+};
+
+export const hasClosestByHeadings = (element: Node) => {
+    const headingElement = hasClosestByTag(element, "H");
+    if (headingElement && headingElement.tagName.length === 2 && headingElement.tagName !== "HR") {
+        return headingElement;
+    }
+    return false;
+};

+ 2 - 2
src/ts/wysiwyg/highlightToolbar.ts

@@ -14,11 +14,11 @@ import {isCtrl, updateHotkeyTip} from "../util/compatibility";
 import {setTableAlign} from "../util/fixBrowserBehavior";
 import {
     hasClosestByAttribute,
-    hasClosestByClassName, hasClosestByHeadings,
+    hasClosestByClassName,
     hasClosestByMatchTag,
-    hasClosestByTag,
     hasTopClosestByTag,
 } from "../util/hasClosest";
+import {   hasClosestByHeadings,  hasClosestByTag} from '../util/hasClosestByHEadings'
 import {processCodeRender} from "../util/processCode";
 import {getEditorRange, selectIsEditor, setRangeByWbr, setSelectionFocus} from "../util/selection";
 import {afterRenderEvent} from "./afterRenderEvent";

+ 4 - 3
src/ts/wysiwyg/index.ts

@@ -5,7 +5,7 @@ import {focusEvent, hotkeyEvent, scrollCenter, selectEvent} from "../util/editor
 import {isHeadingMD, isHrMD, paste, renderToc} from "../util/fixBrowserBehavior";
 import {
     hasClosestBlock, hasClosestByAttribute,
-    hasClosestByClassName, hasClosestByHeadings, hasClosestByMatchTag,
+    hasClosestByClassName, hasClosestByMatchTag,
 } from "../util/hasClosest";
 import {
     getEditorRange,
@@ -17,6 +17,7 @@ import {genImagePopover, highlightToolbar} from "./highlightToolbar";
 import {getRenderElementNextNode, modifyPre} from "./inlineTag";
 import {input} from "./input";
 import {showCode} from "./showCode";
+import {hasClosestByHeadings} from "../util/hasClosestByHEadings";
 
 class WYSIWYG {
     public element: HTMLPreElement;
@@ -150,7 +151,7 @@ class WYSIWYG {
             const headingElement = hasClosestByHeadings(getSelection().getRangeAt(0).startContainer);
             if (headingElement && headingElement.textContent === "") {
                 // heading 为空删除 https://github.com/Vanessa219/vditor/issues/150
-                renderToc(this.element);
+                renderToc(vditor);
                 return;
             }
             input(vditor, getSelection().getRangeAt(0).cloneRange(), event);
@@ -204,7 +205,7 @@ class WYSIWYG {
             const headingElement = hasClosestByHeadings(getSelection().getRangeAt(0).startContainer);
             if (headingElement && headingElement.textContent === "") {
                 // heading 为空删除 https://github.com/Vanessa219/vditor/issues/150
-                renderToc(this.element);
+                renderToc(vditor);
                 return;
             }
 

+ 2 - 3
src/ts/wysiwyg/input.ts

@@ -2,14 +2,13 @@ import {isToC, renderToc} from "../util/fixBrowserBehavior";
 import {
     getTopList,
     hasClosestBlock, hasClosestByAttribute,
-    hasClosestByHeadings,
-    hasClosestByTag,
 } from "../util/hasClosest";
 import {log} from "../util/log";
 import {processCodeRender} from "../util/processCode";
 import {setRangeByWbr} from "../util/selection";
 import {afterRenderEvent} from "./afterRenderEvent";
 import {previoueIsEmptyA} from "./inlineTag";
+import {hasClosestByHeadings, hasClosestByTag} from "../util/hasClosestByHEadings";
 
 export const input = (vditor: IVditor, range: Range, event?: InputEvent) => {
     let blockElement = hasClosestBlock(range.startContainer);
@@ -39,7 +38,7 @@ export const input = (vditor: IVditor, range: Range, event?: InputEvent) => {
         }
 
         if (hasClosestByHeadings(blockElement)) {
-            renderToc(vditor.wysiwyg.element);
+            renderToc(vditor);
         }
 
         // 保存光标

+ 2 - 1
src/ts/wysiwyg/processKeydown.ts

@@ -13,7 +13,7 @@ import {
 import {
     hasClosestBlock,
     hasClosestByAttribute,
-    hasClosestByClassName, hasClosestByHeadings,
+    hasClosestByClassName,
     hasClosestByMatchTag,
     hasTopClosestByTag,
 } from "../util/hasClosest";
@@ -23,6 +23,7 @@ import {afterRenderEvent} from "./afterRenderEvent";
 import {nextIsCode} from "./inlineTag";
 import {removeHeading, setHeading} from "./setHeading";
 import {showCode} from "./showCode";
+import {hasClosestByHeadings} from "../util/hasClosestByHEadings";
 
 export const processKeydown = (vditor: IVditor, event: KeyboardEvent) => {
     // Chrome firefox 触发 compositionend 机制不一致 https://github.com/Vanessa219/vditor/issues/188

+ 1 - 1
src/ts/wysiwyg/setHeading.ts

@@ -24,7 +24,7 @@ export const setHeading = (vditor: IVditor, tagName: string) => {
             blockElement.outerHTML = `<${tagName} data-block="0">${blockElement.innerHTML.trim()}</${tagName}>`;
         }
         setRangeByWbr(vditor.wysiwyg.element, range);
-        renderToc(vditor.wysiwyg.element);
+        renderToc(vditor);
     }
 };