UNPKG

vditor

Version:

♏ 易于使用的 Markdown 编辑器,为适配不同的应用场景而生

579 lines (531 loc) 26.3 kB
import {Constants} from "../constants"; import {hidePanel} from "../toolbar/setToolbar"; import {isCtrl, isFirefox} from "../util/compatibility"; import { blurEvent, copyEvent, cutEvent, dblclickEvent, dropEvent, focusEvent, hotkeyEvent, scrollCenter, selectEvent, } from "../util/editorCommonEvent"; import {isHeadingMD, isHrMD, paste} from "../util/fixBrowserBehavior"; import { hasClosestBlock, hasClosestByAttribute, hasClosestByClassName, hasClosestByMatchTag, } from "../util/hasClosest"; import {hasClosestByHeadings} from "../util/hasClosestByHeadings"; import { getCursorPosition, getEditorRange, getSelectPosition, setRangeByWbr, setSelectionFocus, } from "../util/selection" import {clickToc, renderToc} from "../util/toc"; import {afterRenderEvent} from "./afterRenderEvent"; import {genImagePopover, genLinkRefPopover, highlightToolbarWYSIWYG} from "./highlightToolbarWYSIWYG"; import {getRenderElementNextNode, modifyPre} from "./inlineTag"; import {input} from "./input"; import {showCode} from "./showCode"; import {getMarkdown} from "../markdown/getMarkdown"; class WYSIWYG { public range: Range; public element: HTMLPreElement; public popover: HTMLDivElement; public selectPopover: HTMLDivElement; public afterRenderTimeoutId: number; public hlToolbarTimeoutId: number; public preventInput: boolean; public composingLock = false; public commentIds: string[] = []; private scrollListener: () => void; constructor(vditor: IVditor) { const divElement = document.createElement("div"); divElement.className = "vditor-wysiwyg"; divElement.innerHTML = `<pre class="vditor-reset" placeholder="${vditor.options.placeholder}" contenteditable="true" spellcheck="false"></pre> <div class="vditor-panel vditor-panel--none"></div> <div class="vditor-panel vditor-panel--none"> <button type="button" aria-label="${window.VditorI18n.comment}" class="vditor-icon vditor-tooltipped vditor-tooltipped__n"> <svg><use xlink:href="#vditor-icon-comment"></use></svg> </button> </div>`; this.element = divElement.firstElementChild as HTMLPreElement; this.popover = divElement.firstElementChild.nextElementSibling as HTMLDivElement; this.selectPopover = divElement.lastElementChild as HTMLDivElement; this.bindEvent(vditor); focusEvent(vditor, this.element); dblclickEvent(vditor, this.element); blurEvent(vditor, this.element); hotkeyEvent(vditor, this.element); selectEvent(vditor, this.element); dropEvent(vditor, this.element); copyEvent(vditor, this.element, this.copy); cutEvent(vditor, this.element, this.copy); if (vditor.options.comment.enable) { this.selectPopover.querySelector("button").onclick = () => { const id = Lute.NewNodeID(); const range = getSelection().getRangeAt(0); const rangeClone = range.cloneRange(); const contents = range.extractContents(); let blockStartElement: HTMLElement; let blockEndElement: HTMLElement; let removeStart = false; let removeEnd = false; contents.childNodes.forEach((item: HTMLElement, index: number) => { let wrap = false; if (item.nodeType === 3) { wrap = true; } else if (!item.classList.contains("vditor-comment")) { wrap = true; } else if (item.classList.contains("vditor-comment")) { item.setAttribute("data-cmtids", item.getAttribute("data-cmtids") + " " + id); } if (wrap) { if (item.nodeType !== 3 && item.getAttribute("data-block") === "0" && index === 0 && rangeClone.startOffset > 0) { item.innerHTML = `<span class="vditor-comment" data-cmtids="${id}">${item.innerHTML}</span>`; blockStartElement = item; } else if (item.nodeType !== 3 && item.getAttribute("data-block") === "0" && index === contents.childNodes.length - 1 && rangeClone.endOffset < rangeClone.endContainer.textContent.length) { item.innerHTML = `<span class="vditor-comment" data-cmtids="${id}">${item.innerHTML}</span>`; blockEndElement = item; } else if (item.nodeType !== 3 && item.getAttribute("data-block") === "0") { if (index === 0) { removeStart = true; } else if (index === contents.childNodes.length - 1) { removeEnd = true; } item.innerHTML = `<span class="vditor-comment" data-cmtids="${id}">${item.innerHTML}</span>`; } else { const commentElement = document.createElement("span"); commentElement.classList.add("vditor-comment"); commentElement.setAttribute("data-cmtids", id); item.parentNode.insertBefore(commentElement, item); commentElement.appendChild(item); } } }); const startElement = hasClosestBlock(rangeClone.startContainer); if (startElement) { if (blockStartElement) { startElement.insertAdjacentHTML("beforeend", blockStartElement.innerHTML); blockStartElement.remove(); } else if (startElement.textContent.trim().replace(Constants.ZWSP, "") === "" && removeStart) { startElement.remove(); } } const endElement = hasClosestBlock(rangeClone.endContainer); if (endElement) { if (blockEndElement) { endElement.insertAdjacentHTML("afterbegin", blockEndElement.innerHTML); blockEndElement.remove(); } else if (endElement.textContent.trim().replace(Constants.ZWSP, "") === "" && removeEnd) { endElement.remove(); } } range.insertNode(contents); vditor.options.comment.add(id, range.toString(), this.getComments(vditor, true)); afterRenderEvent(vditor, { enableAddUndoStack: true, enableHint: false, enableInput: false, }); this.hideComment(); }; } } public getComments(vditor: IVditor, getData = false) { if (vditor.currentMode === "wysiwyg" && vditor.options.comment.enable) { this.commentIds = []; this.element.querySelectorAll(".vditor-comment").forEach((item) => { this.commentIds = this.commentIds.concat(item.getAttribute("data-cmtids").split(" ")); }); this.commentIds = Array.from(new Set(this.commentIds)); const comments: ICommentsData[] = []; if (getData) { this.commentIds.forEach((id) => { comments.push({ id, top: (this.element.querySelector(`.vditor-comment[data-cmtids="${id}"]`) as HTMLElement).offsetTop, }); }); return comments; } } else { return []; } } public triggerRemoveComment(vditor: IVditor) { const difference = (a: string[], b: string[]) => { const s = new Set(b); return a.filter((x) => !s.has(x)); }; if (vditor.currentMode === "wysiwyg" && vditor.options.comment.enable && vditor.wysiwyg.commentIds.length > 0) { const oldIds = JSON.parse(JSON.stringify(this.commentIds)); this.getComments(vditor); const removedIds = difference(oldIds, this.commentIds); if (removedIds.length > 0) { vditor.options.comment.remove(removedIds); } } } public showComment() { const position = getCursorPosition(this.element); this.selectPopover.setAttribute("style", `left:${position.left}px;display:block;top:${Math.max(-8, position.top - 21)}px`); } public hideComment() { this.selectPopover.setAttribute("style", "display:none"); } public unbindListener() { window.removeEventListener("scroll", this.scrollListener); } private copy(event: ClipboardEvent, vditor: IVditor) { const range = getSelection().getRangeAt(0); if (range.toString() === "") { return; } event.stopPropagation(); event.preventDefault(); const codeElement = hasClosestByMatchTag(range.startContainer, "CODE"); const codeEndElement = hasClosestByMatchTag(range.endContainer, "CODE"); if (codeElement && codeEndElement && codeEndElement.isSameNode(codeElement)) { let codeText = ""; if (codeElement.parentElement.tagName === "PRE") { codeText = range.toString(); } else { codeText = "`" + range.toString() + "`"; } event.clipboardData.setData("text/plain", codeText); event.clipboardData.setData("text/html", ""); return; } const aElement = hasClosestByMatchTag(range.startContainer, "A"); const aEndElement = hasClosestByMatchTag(range.endContainer, "A"); if (aElement && aEndElement && aEndElement.isSameNode(aElement)) { let aTitle = aElement.getAttribute("title") || ""; if (aTitle) { aTitle = ` "${aTitle}"`; } event.clipboardData.setData("text/plain", `[${range.toString()}](${aElement.getAttribute("href")}${aTitle})`); event.clipboardData.setData("text/html", ""); return; } const tempElement = document.createElement("div"); tempElement.appendChild(range.cloneContents()); event.clipboardData.setData("text/plain", vditor.lute.VditorDOM2Md(tempElement.innerHTML).trim()); event.clipboardData.setData("text/html", ""); } private bindEvent(vditor: IVditor) { this.unbindListener(); window.addEventListener("scroll", this.scrollListener = () => { hidePanel(vditor, ["hint"]); if (this.popover.style.display !== "block" || this.selectPopover.style.display !== "block") { return; } const top = parseInt(this.popover.getAttribute("data-top"), 10); if (vditor.options.height !== "auto") { if (vditor.options.toolbarConfig.pin && vditor.toolbar.element.getBoundingClientRect().top === 0) { const popoverTop = Math.max(window.scrollY - vditor.element.offsetTop - 8, Math.min(top - vditor.wysiwyg.element.scrollTop, this.element.clientHeight - 21)) + "px"; if (this.popover.style.display === "block") { this.popover.style.top = popoverTop; } if (this.selectPopover.style.display === "block") { this.selectPopover.style.top = popoverTop; } } return; } else if (!vditor.options.toolbarConfig.pin) { return; } const popoverTop1 = Math.max(top, (window.scrollY - vditor.element.offsetTop - 8)) + "px"; if (this.popover.style.display === "block") { this.popover.style.top = popoverTop1; } if (this.selectPopover.style.display === "block") { this.selectPopover.style.top = popoverTop1; } }); this.element.addEventListener("scroll", () => { hidePanel(vditor, ["hint"]); if (vditor.options.comment && vditor.options.comment.enable && vditor.options.comment.scroll) { vditor.options.comment.scroll(vditor.wysiwyg.element.scrollTop); } if (this.popover.style.display !== "block") { return; } const top = parseInt(this.popover.getAttribute("data-top"), 10) - vditor.wysiwyg.element.scrollTop; let max = -8; if (vditor.options.toolbarConfig.pin && vditor.toolbar.element.getBoundingClientRect().top === 0) { max = window.scrollY - vditor.element.offsetTop + max; } const topPx = Math.max(max, Math.min(top, this.element.clientHeight - 21)) + "px"; this.popover.style.top = topPx; this.selectPopover.style.top = topPx; }); this.element.addEventListener("paste", (event: ClipboardEvent & { target: HTMLElement }) => { paste(vditor, event, { pasteCode: (code: string) => { const range = getEditorRange(vditor); const node = document.createElement("template"); node.innerHTML = code; range.insertNode(node.content.cloneNode(true)); const blockElement = hasClosestByAttribute(range.startContainer, "data-block", "0"); if (blockElement) { blockElement.outerHTML = vditor.lute.SpinVditorDOM(blockElement.outerHTML); } else { vditor.wysiwyg.element.innerHTML = vditor.lute.SpinVditorDOM(vditor.wysiwyg.element.innerHTML); } setRangeByWbr(vditor.wysiwyg.element, range); }, }); }); // 中文处理 this.element.addEventListener("compositionstart", () => { this.composingLock = true; }); this.element.addEventListener("compositionend", (event: InputEvent) => { const headingElement = hasClosestByHeadings(getSelection().getRangeAt(0).startContainer); if (headingElement && headingElement.textContent === "") { // heading 为空删除 https://github.com/Vanessa219/vditor/issues/150 renderToc(vditor); return; } if (!isFirefox()) { input(vditor, getSelection().getRangeAt(0).cloneRange(), event); } this.composingLock = false; }); this.element.addEventListener("input", (event: InputEvent) => { if (event.inputType === "deleteByDrag" || event.inputType === "insertFromDrop") { // https://github.com/Vanessa219/vditor/issues/801 编辑器内容拖拽问题 return; } if (this.preventInput) { this.preventInput = false; afterRenderEvent(vditor); return; } if (this.composingLock || event.data === "‘" || event.data === "“" || event.data === "《") { afterRenderEvent(vditor); return; } const range = getSelection().getRangeAt(0); let blockElement = hasClosestBlock(range.startContainer); if (!blockElement) { // 没有被块元素包裹 modifyPre(vditor, range); blockElement = hasClosestBlock(range.startContainer); } if (!blockElement) { return; } // 前后空格处理 const startOffset = getSelectPosition(blockElement, vditor.wysiwyg.element, range).start; // 开始可以输入空格 let startSpace = true; for (let i = startOffset - 1; i > blockElement.textContent.substr(0, startOffset).lastIndexOf("\n"); i--) { if (blockElement.textContent.charAt(i) !== " " && // 多个 tab 前删除不形成代码块 https://github.com/Vanessa219/vditor/issues/162 1 blockElement.textContent.charAt(i) !== "\t") { startSpace = false; break; } } if (startOffset === 0) { startSpace = false; } // 结尾可以输入空格 let endSpace = true; for (let i = startOffset - 1; i < blockElement.textContent.length; i++) { if (blockElement.textContent.charAt(i) !== " " && blockElement.textContent.charAt(i) !== "\n") { endSpace = false; break; } } // https://github.com/Vanessa219/vditor/issues/729 if (endSpace && /^#{1,6} $/.test(blockElement.textContent)) { endSpace = false; } const headingElement = hasClosestByHeadings(getSelection().getRangeAt(0).startContainer); if (headingElement && headingElement.textContent === "") { // heading 为空删除 https://github.com/Vanessa219/vditor/issues/150 renderToc(vditor); headingElement.remove(); } if ((startSpace && blockElement.getAttribute("data-type") !== "code-block") || endSpace || isHeadingMD(blockElement.innerHTML) || (isHrMD(blockElement.innerHTML) && blockElement.previousElementSibling)) { if (typeof vditor.options.input === "function") { vditor.options.input(getMarkdown(vditor)); } return; } // https://github.com/Vanessa219/vditor/issues/1565 if (event.inputType === "insertParagraph" && this.element.innerHTML === '<p><br></p><p><br></p>') { blockElement.previousElementSibling.remove(); } input(vditor, range, event); }); this.element.addEventListener("click", (event: MouseEvent & { target: HTMLElement }) => { if (event.target.tagName === "INPUT") { const checkElement = event.target as HTMLInputElement; if (checkElement.checked) { checkElement.setAttribute("checked", "checked"); } else { checkElement.removeAttribute("checked"); } this.preventInput = true; if (getSelection().rangeCount > 0) { setSelectionFocus(getSelection().getRangeAt(0)); } afterRenderEvent(vditor); return; } if (event.target.tagName === "IMG" && // plantuml 图片渲染不进行提示 !event.target.parentElement.classList.contains("vditor-wysiwyg__preview")) { if (event.target.getAttribute("data-type") === "link-ref") { genLinkRefPopover(vditor, event.target); } else { genImagePopover(event, vditor); } return; } // 打开链接 const a = hasClosestByMatchTag(event.target, "A"); if (a) { if (vditor.options.link.click) { vditor.options.link.click(a); } else if (vditor.options.link.isOpen) { window.open(a.getAttribute("href")); } event.preventDefault(); return; } const range = getEditorRange(vditor); if (event.target.isEqualNode(this.element) && this.element.lastElementChild && range.collapsed) { const lastRect = this.element.lastElementChild.getBoundingClientRect(); if (event.y > lastRect.top + lastRect.height) { if (this.element.lastElementChild.tagName === "P" && this.element.lastElementChild.textContent.trim().replace(Constants.ZWSP, "") === "") { range.selectNodeContents(this.element.lastElementChild); range.collapse(false); } else { this.element.insertAdjacentHTML("beforeend", `<p data-block="0">${Constants.ZWSP}<wbr></p>`); setRangeByWbr(this.element, range); } } } highlightToolbarWYSIWYG(vditor); // 点击后光标落于预览区,需展开代码块 let previewElement = hasClosestByClassName(event.target, "vditor-wysiwyg__preview"); if (!previewElement) { previewElement = hasClosestByClassName(getEditorRange(vditor).startContainer, "vditor-wysiwyg__preview"); } if (previewElement) { showCode(previewElement, vditor); } clickToc(event, vditor); }); this.element.addEventListener("keyup", (event: KeyboardEvent & { target: HTMLElement }) => { if (event.isComposing || isCtrl(event)) { return; } // 除 md 处理、cell 内换行、table 添加新行/列、代码块语言切换、block render 换行、跳出/逐层跳出 blockquote、h6 换行、 // 任务列表换行、软换行外需在换行时调整文档位置 if (event.key === "Enter") { scrollCenter(vditor); } if ((event.key === "Backspace" || event.key === "Delete") && vditor.wysiwyg.element.innerHTML !== "" && vditor.wysiwyg.element.childNodes.length === 1 && vditor.wysiwyg.element.firstElementChild && vditor.wysiwyg.element.firstElementChild.tagName === "P" && vditor.wysiwyg.element.firstElementChild.childElementCount === 0 && (vditor.wysiwyg.element.textContent === "" || vditor.wysiwyg.element.textContent === "\n")) { // 为空时显示 placeholder vditor.wysiwyg.element.innerHTML = ""; } const range = getEditorRange(vditor); if (event.key === "Backspace") { // firefox headings https://github.com/Vanessa219/vditor/issues/211 if (isFirefox() && range.startContainer.textContent === "\n" && range.startOffset === 1) { range.startContainer.textContent = ""; } } // 没有被块元素包裹 modifyPre(vditor, range); highlightToolbarWYSIWYG(vditor); if (event.key !== "ArrowDown" && event.key !== "ArrowRight" && event.key !== "Backspace" && event.key !== "ArrowLeft" && event.key !== "ArrowUp") { return; } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { vditor.hint.render(vditor); } // 上下左右,删除遇到块预览的处理 let previewElement = hasClosestByClassName(range.startContainer, "vditor-wysiwyg__preview"); if (!previewElement && range.startContainer.nodeType !== 3 && range.startOffset > 0) { // table 前删除遇到代码块 const blockRenderElement = range.startContainer as HTMLElement; if (blockRenderElement.classList.contains("vditor-wysiwyg__block")) { previewElement = blockRenderElement.lastElementChild as HTMLElement; } } if (!previewElement) { return; } const previousElement = previewElement.previousElementSibling as HTMLElement; if (previousElement.style.display === "none") { if (event.key === "ArrowDown" || event.key === "ArrowRight") { showCode(previewElement, vditor); } else { showCode(previewElement, vditor, false); } return; } let codeElement = previewElement.previousElementSibling as HTMLElement; if (codeElement.tagName === "PRE") { codeElement = codeElement.firstElementChild as HTMLElement; } if (event.key === "ArrowDown" || event.key === "ArrowRight") { const blockRenderElement = previewElement.parentElement; let nextNode = getRenderElementNextNode(blockRenderElement) as HTMLElement; if (nextNode && nextNode.nodeType !== 3) { // 下一节点依旧为代码渲染块 const nextRenderElement = nextNode.querySelector(".vditor-wysiwyg__preview") as HTMLElement; if (nextRenderElement) { showCode(nextRenderElement, vditor); return; } } // 跳过渲染块,光标移动到下一个节点 if (nextNode.nodeType === 3) { // inline while (nextNode.textContent.length === 0 && nextNode.nextSibling) { // https://github.com/Vanessa219/vditor/issues/100 2 nextNode = nextNode.nextSibling as HTMLElement; } range.setStart(nextNode, 1); } else { // block range.setStart(nextNode.firstChild, 0); } } else { range.selectNodeContents(codeElement); range.collapse(false); } }); } } export {WYSIWYG};