diff --git a/README.md b/README.md index 24f51fe5..3c9f08ac 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ JSBridge.invoke('executeJavaScript', 1, "_myValue=123; JSBridge.invoke('executeJ -### multi_highlighter:多关键字高亮(暂时禁用) +### multi_highlighter:多关键字高亮 搜索并高亮关键字,并提供一键定位功能(左键下一个,右键上一个) @@ -147,7 +147,7 @@ JSBridge.invoke('executeJavaScript', 1, "_myValue=123; JSBridge.invoke('executeJ > 注意:当你鼠标点击文档内容时,会自动退出高亮状态。**这是 Typora 本身的限制导致的**:高亮功能是通过添加标签实现的,但是为了保证数据安全,`#write` 标签不允许手动添加任何标签,所以需要在编辑的时候 remove 掉之前添加的标签。(你可以试试 Typora 自身的 ctrl+F 搜索,在搜索关键字后,点击任意地方原先高亮的地方也会消失) -> 已知BUG:**Typora 对于大文件会惰性加载代码块,导致代码块里面添加的标签会被刷新掉。暂时默认关闭此脚本,待解决后重新开启。** +> 注意:此脚本在使用期间会消耗大量内存,**不建议在大文件中使用**。 diff --git a/plugin/_obgnail__typora_plugin.url b/plugin/_obgnail__typora_plugin.url new file mode 100644 index 00000000..6f4eb0df --- /dev/null +++ b/plugin/_obgnail__typora_plugin.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=https://github.com/obgnail/typora_plugin diff --git a/plugin/index.js b/plugin/index.js index 4de9caa1..26bb389f 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -19,7 +19,7 @@ window.onload = () => { name: "多关键字高亮", fixed_name: "multi_highlighter", src: "./plugin/multi_highlighter/index.js", - enable: false, + enable: true, clickable: true, }, { diff --git a/plugin/multi_highlighter/highlighter.js b/plugin/multi_highlighter/highlighter.js new file mode 100644 index 00000000..081a1bae --- /dev/null +++ b/plugin/multi_highlighter/highlighter.js @@ -0,0 +1,431 @@ +const StateTransition = { + empty: 0, + valid: 1, + match: 2 +} + +/** + * Search and higlight results within the given html root + * Instanciate by giving it a `root` and a search `token` + * + * Here is what a SearchToken should look like + * SearchToken { + * text: string; + * className?: string; + * caseSensitive?: boolean; + * } + * + * @param root - the root html container to perform the search in + * @param token - a search token as described + * @param scrollToResult - whether or not selecting a result should scroll it into view + * @param defaultClassName + * @param defaultCaseSensitive + */ +class InstantSearch { + constructor( + root, + token, + scrollToResult = true, + defaultClassName = "highlight", + defaultCaseSensitive = false, + ) { + this.state = {} + this.root = root + this.token = token + this.scrollToResult = scrollToResult + this.defaultClassName = defaultClassName + this.defaultCaseSensitive = defaultCaseSensitive + this.matches = [] + this.perfs = [] + } + + /** + * Search and highlight occurrences of the current token in the current root + */ + highlight() { + this.matches = [] + this.state[this.token.text] = {} + if (this.token.text.length > 0) { + const t1 = performance.now() + this.walk(this.root) + const t2 = performance.now() + this.perfs.push({event: "Search text", time: t2 - t1}) + + // reverse so the previous match offset don't change when wrapping the result + const t3 = performance.now() + this.matches.reverse().forEach(m => { + const className = m.token.className || this.defaultClassName + const range = this.createRange(m.startNode, m.startOffset, m.endNode, m.endOffset) + this.wrapRange(range, className, m.startNode, m.endNode) + }) + const t4 = performance.now() + this.perfs.push({event: "Highlight text", time: t4 - t3}) + } + } + + /** + * Remove all highlights from the current root + */ + removeHighlight() { + const t1 = performance.now() + let element + if (this.root instanceof Element) { + element = this.root + } else if (this.root.parentElement) { + element = this.root.parentElement + } + const className = this.token.className || this.defaultClassName + element && element.querySelectorAll(`.${className}`).forEach( + el => { + const fragment = document.createDocumentFragment() + const childNodes = el.childNodes + fragment.append(...Array.from(childNodes)) + const parent = el.parentNode + parent && parent.replaceChild(fragment, el) + parent && parent.normalize() + this.mergeAdjacentSimilarNodes(parent) + } + ) + const t2 = performance.now() + this.perfs.push({event: "Remove highlights", time: t2 - t1}) + } + + /** + * Merge adjacent nodes if they are instances of the same tag + * @param parent + */ + mergeAdjacentSimilarNodes(parent) { + if (parent && parent.childNodes) { + Array.from(parent.childNodes).reduce((acc, val) => { + if (val instanceof Element) { + if (acc && acc.tagName.toLowerCase() === val.tagName.toLowerCase()) { + acc.append(...Array.from(val.childNodes)) + parent.removeChild(val) + acc && this.mergeAdjacentSimilarNodes(acc) + } else { + acc && this.mergeAdjacentSimilarNodes(acc) + acc = val + } + } else { + acc && this.mergeAdjacentSimilarNodes(acc) + acc = undefined + } + return acc + }, undefined) + } + } + + /** + * Advance our state machine character by character in the given node + * @param node + */ + search(node) { + const text = node.textContent + const token = this.token + const state = this.state[token.text] + const caseSensitive = token.caseSensitive || this.defaultCaseSensitive + const tokenStr = caseSensitive ? token.text : token.text.toLowerCase() + + for (let i = 0; i < text.length;) { + const char = text[i] + const next = ( + `${state.current || ""}${caseSensitive ? char : char.toLowerCase()}` + .replace(/\s+/g, " ") + ) + if (next === tokenStr) { + this.transitionState(StateTransition.match, state, node, i, next) + i++ + } else { + const pos = tokenStr.indexOf(next) + if (pos === 0) { + this.transitionState(StateTransition.valid, state, node, i, next) + i++ + } else { + this.transitionState(StateTransition.empty, state, node, i, next) + if (next.length === 1) { + i++ + } + } + } + } + } + + /** + * Execute the given state transition and update the state machine output + * @param type + * @param state + * @param node + * @param index + * @param next + */ + transitionState(type, state, node, index, next) { + // let debug = `next: "${next}"` + switch (type) { + case StateTransition.empty: + // debug += " -> empty state" + this.resetState(state) + break + case StateTransition.valid: + // debug += " -> valid state" + if (!state.current || state.current.length === 0) { + state.startNode = node + state.startOffset = index + } + state.current = next + break + case StateTransition.match: { + const isSingleChar = this.token.text.length === 1 + const startNode = isSingleChar ? node : state.startNode + const startOffset = isSingleChar ? index : state.startOffset + this.matches.push({ + token: this.token, + startNode, + startOffset, + endNode: node, + endOffset: index + 1 + }) + // debug += ( + // `\n[Found match!]\n` + // + `startOffset: ${startOffset} - in "${startNode.textContent}"\n` + // + `endOffset: ${i + 1} - in "${node.textContent}"` + // ) + this.resetState(state) + break + } + default: + break + } + // console.log(debug) + } + + /** + * Create a return a range for the given arguments + * @param startNode + * @param startOffset + * @param endNode + * @param endOffset + */ + createRange(startNode, startOffset, endNode, endOffset) { + const range = new Range() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + return range + } + + /** + * Wrap a range with a with the given className + * @param range + * @param className + * @param startNode + * @param endNode + */ + wrapRange(range, className, startNode, endNode) { + const clonedStartNode = startNode.cloneNode(true) + const clonedEndNode = endNode.cloneNode(true) + const selectedText = range.extractContents() + const marker = document.createElement("marker") + marker.classList.add(className) + marker.appendChild(selectedText) + range.insertNode(marker) + this.removeEmptyDirectSiblings(marker, clonedStartNode, clonedEndNode) + } + + /** + * Remove any empty direct sibling before and after the element + * @param element + * @param clonedStartNode + * @param clonedEndNode + */ + removeEmptyDirectSiblings(element, clonedStartNode, clonedEndNode) { + const remove = (element, originalNode) => { + let keepRemoving = true + while (keepRemoving) { + keepRemoving = this.removeEmptyElement(element, originalNode) + } + } + remove(element.previousElementSibling, clonedStartNode) + remove(element.nextElementSibling, clonedEndNode) + } + + /** + * Remove any empty element that wasn't found in the original (before wrapping) node + * @param element + * @param originalNode + */ + removeEmptyElement(element, originalNode) { + const isInOriginalNode = (element) => originalNode.childNodes + && Array.from(originalNode.childNodes) + .some((c) => (c instanceof Element) && c.outerHTML === element.outerHTML) + if (element) { + if (element.parentNode && !isInOriginalNode(element) && !element.textContent) { + element.parentNode.removeChild(element) + return true + } else if (element.childNodes[0] === element.children[0]) { + return this.removeEmptyElement(element.children[0], originalNode) + } + } + return false + } + + /** + * Resets the state to be empty + * @param state + */ + resetState(state) { + delete state.current + delete state.startNode + delete state.startOffset + return state + } + + /** + * Walk through the current root TextNodes + * @param node + */ + walk(node) { + let currentParent = undefined + const treeWalker = document.createTreeWalker( + node, + NodeFilter.SHOW_TEXT + ) + + while (treeWalker.nextNode()) { + const current = treeWalker.currentNode + if (current.parentElement) { + const parent = current.parentElement + const display = getComputedStyle(parent).display + if ( + !["", "contents", "inline", "inline-block"].includes(display) + && currentParent !== parent + ) { + this.resetState(this.state[this.token.text]) + currentParent = parent + } + } + this.search(current) + } + } + + /** + * Get the current highlighted results elements collection, the current active one, + * and its corresponding index in the results collection + */ + getResultsElements() { + const className = this.token.className || this.defaultClassName + const results = this.root.querySelectorAll(`.${className}`) + const active = this.root.querySelector(`.${className}.active`) + const activeIndex = Array.from(results).findIndex(el => el === active) + return { + results, + active, + activeIndex + } + } + + /** + * Switch selected result from the current one to the next one, open any closed detail + * ancestor and scroll the next selected result into view should it not be visible already + * @param active + * @param next + * @param results + */ + switchSelected(active, next, results) { + const didOpenDetails = this.openDetailsAncestors(next) + if (didOpenDetails) { + this.resyncAnimations(results) + } + active && active.classList.remove("active") + next && next.classList.add("active") + if (this.scrollToResult) { + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + console.log(entry) + if (entry.target === next && !entry.isIntersecting) { + console.log(entry.isIntersecting) + observer.unobserve(next) + observer.disconnect() + requestAnimationFrame( + () => next.scrollIntoView({block: "center", behavior: "smooth"}) + ) + } else { + observer.unobserve(entry.target) + } + } + }) + if (next) { + observer.observe(next) + } + } + } + + /** + * Open any closed detail ancestor to the given element + * @param element + */ + openDetailsAncestors(element) { + const detailsAncestors = this.getDetailsAncestors(element) + let didOpenDetails = false + detailsAncestors.forEach(d => { + if (!d.open && !d.children[0].contains(element)) { + d.open = true + didOpenDetails = true + } + }) + return didOpenDetails + } + + /** + * Restart all the animations so they are in sync + * When toggling content (like, when opening details), we sometimes need this + */ + resyncAnimations(results) { + const className = this.token.className || this.defaultClassName + results.forEach(r => { + r.classList.remove(className) + requestAnimationFrame(() => r.classList.add(className)) + }) + } + + /** + * Cycle through results and select the next one, or the first one when + * no result is currently selected + */ + selectNextResult() { + const {results, active, activeIndex} = this.getResultsElements() + const length = results.length + const index = (activeIndex + 1) % length + this.switchSelected(active, results[index], results) + } + + /** + * Cycle through results and select the previous one, or the last one when + * no result is currently selected + */ + selectPrevResult() { + const {results, active, activeIndex} = this.getResultsElements() + const length = results.length + const index = ((activeIndex > 0 ? activeIndex : length) - 1) + this.switchSelected(active, results[index], results) + } + + /** + * Get all the
ancestors for a given element, including the element + * itself if it's a
element + */ + getDetailsAncestors(element) { + const details = [] + let current = element + while (current) { + if ( + current instanceof HTMLDetailsElement + ) { + details.push(current) + } + current = current.parentElement + } + return details + } + +} + +module.exports = {InstantSearch}; \ No newline at end of file diff --git a/plugin/multi_highlighter/index.js b/plugin/multi_highlighter/index.js index 650a132d..dd57c236 100644 --- a/plugin/multi_highlighter/index.js +++ b/plugin/multi_highlighter/index.js @@ -47,10 +47,12 @@ // 当搜索关键字数量超出STYLE_COLOR范围时面板显示的颜色(页面中无颜色) // 20个关键字肯定够用了,此选项没太大意义 DEFAULT_COLOR: "aquamarine", + + LOOP_DETECT_INTERVAL: 20, }; (() => { - const undo_style = { + const run_style = { input_width: (config.SHOW_RUN_BUTTON) ? "95%" : "100%", case_button_right: (config.SHOW_RUN_BUTTON) ? "32px" : "6px", run_button_display: (config.SHOW_RUN_BUTTON) ? "" : "none", @@ -58,146 +60,139 @@ const colors = config.STYLE_COLOR.map((color, idx) => `.plugin-search-hit${idx} { background-color: ${color}; }`) const colorsStyle = colors.join("\n"); - const modal_css = ` - #plugin-multi-highlighter { - position: fixed; - top: 15%; - left: 55%; - width: 500px; - z-index: 9999; - padding: 4px; - background-color: #f8f8f8; - box-shadow: 0 4px 10px rgba(0, 0, 0, .5); - border: 1px solid #ddd; - border-top: none; - color: var(--text-color); - transform: translate3d(0, 0, 0) - } - - .mac-seamless-mode #plugin-multi-highlighter { - top: 30px - } - - #plugin-multi-highlighter-input { - position: relative; - } - - #plugin-multi-highlighter-input input { - width: ${undo_style.input_width}; - font-size: 14px; - line-height: 25px; - max-height: 27px; - overflow: auto; - border: 1px solid #ddd; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); - border-radius: 2px; - padding-left: 5px; - padding-right: 30px; - } - - #plugin-multi-highlighter-input input:focus { - outline: 0 - } - - #plugin-multi-highlighter-input svg { - width: 20px; - height: 14px; - stroke: none; - fill: currentColor - } - - #plugin-multi-highlighter-input .plugin-multi-highlighter-option-btn { - position: absolute; - padding: 1px; - right: ${undo_style.case_button_right}; - top: 8px; - opacity: .5; - line-height: 10px; - border-radius: 3px; - cursor: pointer; - } - - #plugin-multi-highlighter-input .plugin-multi-highlighter-option-btn.select, - #plugin-multi-highlighter-input .plugin-multi-highlighter-option-btn:hover { - background: var(--active-file-bg-color); - color: var(--active-file-text-color); - opacity: 1 - } - - #plugin-multi-highlighter-input .run-highlight { - margin-left: 4px; - opacity: .5; - cursor: pointer; - display: ${undo_style.run_button_display}; - } - - #plugin-multi-highlighter-input .run-highlight:hover { - opacity: 1 !important; - } - - #plugin-multi-highlighter-result { - display: inline-flex; - flex-wrap: wrap; - align-content: flex-start; - } - - .plugin-multi-highlighter-result-item { - font-family: Arial; - cursor: pointer; - font-size: 13px; - line-height: 20px; - margin: 3px 3px; - padding: 0 5px; - border-radius: 5px; - } - - .plugin-multi-highlighter-move { - outline: 4px solid #FF7B00; - text-decoration: blink; - } - - .plugin-multi-highlighter-bar { - background: rgba(29,163,63,.3); - position: absolute; - z-index: 99999; - animation-name: fadeit; - animation-duration: 3s; - } - - @keyframes fadeit { - from {opacity:1;} - to {opacity:0;} - } - - ${colorsStyle} - ` + const css = ` + #plugin-multi-highlighter { + position: fixed; + top: 15%; + left: 55%; + width: 500px; + z-index: 9999; + padding: 4px; + background-color: #f8f8f8; + box-shadow: 0 4px 10px rgba(0, 0, 0, .5); + border: 1px solid #ddd; + border-top: none; + color: var(--text-color); + transform: translate3d(0, 0, 0) + } + + .mac-seamless-mode #plugin-multi-highlighter { + top: 30px + } + + #plugin-multi-highlighter-input { + position: relative; + } + + #plugin-multi-highlighter-input input { + width: ${run_style.input_width}; + font-size: 14px; + line-height: 25px; + max-height: 27px; + overflow: auto; + border: 1px solid #ddd; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + border-radius: 2px; + padding-left: 5px; + padding-right: 30px; + } + + #plugin-multi-highlighter-input svg { + width: 20px; + height: 14px; + stroke: none; + fill: currentColor + } + + #plugin-multi-highlighter-input .plugin-multi-highlighter-option-btn { + position: absolute; + padding: 1px; + right: ${run_style.case_button_right}; + top: 8px; + opacity: .5; + line-height: 10px; + border-radius: 3px; + cursor: pointer; + } + + #plugin-multi-highlighter-input .plugin-multi-highlighter-option-btn.select, .plugin-multi-highlighter-option-btn:hover { + background: var(--active-file-bg-color); + color: var(--active-file-text-color); + opacity: 1 + } + + #plugin-multi-highlighter-input .run-highlight { + margin-left: 4px; + opacity: .5; + cursor: pointer; + display: ${run_style.run_button_display}; + } + + #plugin-multi-highlighter-input .run-highlight:hover { + opacity: 1 !important; + } + + #plugin-multi-highlighter-result { + display: inline-flex; + flex-wrap: wrap; + align-content: flex-start; + } + + .plugin-multi-highlighter-result-item { + font-family: Arial; + cursor: pointer; + font-size: 13px; + line-height: 20px; + margin: 3px 3px; + padding: 0 5px; + border-radius: 5px; + } + + .plugin-multi-highlighter-move { + outline: 4px solid #FF7B00; + text-decoration: blink; + } + + .plugin-multi-highlighter-bar { + background: rgba(29,163,63,.3); + position: absolute; + z-index: 99999; + animation-name: fadeit; + animation-duration: 3s; + } + + @keyframes fadeit { + from {opacity:1;} + to {opacity:0;} + } + + ${colorsStyle} + ` const style = document.createElement('style'); style.type = 'text/css'; - style.innerHTML = modal_css; + style.innerHTML = css; document.getElementsByTagName("head")[0].appendChild(style); - const modal_div = ` -
- - - - - - - -
- - `; + const div = ` +
+ + + + + +
+ ` const searchModal = document.createElement("div"); searchModal.id = 'plugin-multi-highlighter'; searchModal.style.display = "none"; - searchModal.innerHTML = modal_div; + searchModal.innerHTML = div; const quickOpenNode = document.getElementById("typora-quick-open"); quickOpenNode.parentNode.insertBefore(searchModal, quickOpenNode.nextSibling); })() - const modal = { + const entities = { + write: document.getElementById("write"), modal: document.getElementById('plugin-multi-highlighter'), input: document.querySelector("#plugin-multi-highlighter-input input"), runButton: document.querySelector("#plugin-multi-highlighter-input .run-highlight"), @@ -208,52 +203,34 @@ const metaKeyPressed = ev => File.isMac ? ev.metaKey : ev.ctrlKey; const getFilePath = () => File.filePath || File.bundle && File.bundle.filePath; - let _multiHighlighter = null; - const getMultiHighlighter = () => { - if (!_multiHighlighter) { - const dirname = global.dirname || global.__dirname; - const filepath = reqnode('path').join(dirname, "plugin", "multi_highlighter", "multi_highlighter.js"); - const {InstantSearch} = reqnode(filepath); - _multiHighlighter = InstantSearch; - } - return _multiHighlighter + const multiHighlighterClass = reqnode(reqnode('path').join(global.dirname || global.__dirname, + "plugin", "multi_highlighter", "multi_highlighter.js")).multiHighlighter; + const multiHighlighter = new multiHighlighterClass(); + let fenceMultiHighlighterList = []; // 为了解决fence惰性加载的问题 + + const clearHighlight = () => { + multiHighlighter.clear(); + fenceMultiHighlighterList.forEach(highlighter => highlighter.clear()); + fenceMultiHighlighterList = []; + entities.write.querySelectorAll(".plugin-multi-highlighter-bar").forEach( + ele => ele && ele.parentElement && ele.parentElement.removeChild(ele)); } - let searcherList = []; const doSearch = (keyArr, refreshResult = true) => { clearHighlight(); - const searcher = getMultiHighlighter(); - const write = document.querySelector("#write"); - - searcherList = keyArr.map((key, idx) => { - const className = `plugin-search-hit${idx}`; - return new searcher( - write, // root - {text: key, caseSensitive: config.CASE_SENSITIVE, className: className}, //token - true, // scrollToResult - className, // defaultClassName - config.CASE_SENSITIVE, // defaultCaseSensitive - ) - }) - searcherList.forEach(s => s.highlight()); + multiHighlighter.new(keyArr, entities.write, config.CASE_SENSITIVE, "plugin-search-hit"); + multiHighlighter.highlight(); if (refreshResult) { - const inner = searcherList.map((searcher, idx) => { + const itemList = multiHighlighter.getList().map((searcher, idx) => { const color = (idx < config.STYLE_COLOR.length) ? config.STYLE_COLOR[idx] : config.DEFAULT_COLOR; return `
${searcher.token.text} (${searcher.matches.length})
`; }) - modal.result.innerHTML = inner.join(""); + entities.result.innerHTML = itemList.join(""); } - modal.result.style.display = ""; - } - - const clearHighlight = () => { - searcherList.forEach(s => s.removeHighlight()); - searcherList = []; - document.querySelectorAll("#write .plugin-multi-highlighter-bar").forEach( - ele => ele && ele.parentElement && ele.parentElement.removeChild(ele)); + entities.result.style.display = ""; } const refreshFences = () => { @@ -262,76 +239,22 @@ } } - const Call = () => { - modal.modal.style.display = "block"; - modal.input.select(); - } - - window.addEventListener("keydown", ev => { - if (config.HOTKEY(ev)) { - Call(); - ev.preventDefault(); - ev.stopPropagation(); - } - }); - module.exports = {config, Call}; - const getKeyArr = () => { - const value = modal.input.value; + const value = entities.input.value; if (!value) return; return value.split(config.SEPARATOR).filter(Boolean) } - let highlightFilePath; + let lastHighlightFilePath; const highlight = (refreshResult = true) => { - highlightFilePath = getFilePath(); + lastHighlightFilePath = getFilePath(); const keyArr = getKeyArr(); if (!keyArr) return false; doSearch(keyArr, refreshResult); return true; } - modal.input.addEventListener("keydown", ev => { - switch (ev.key) { - case "Enter": - ev.stopPropagation(); - ev.preventDefault(); - highlight(); - break - case "Escape": - ev.stopPropagation(); - ev.preventDefault(); - clearHighlight(); - modal.modal.style.display = "none"; - break - } - }) - - modal.caseOption.addEventListener("click", ev => { - modal.caseOption.classList.toggle("select"); - config.CASE_SENSITIVE = !config.CASE_SENSITIVE; - ev.preventDefault(); - ev.stopPropagation(); - }) - - if (config.SHOW_RUN_BUTTON) { - modal.runButton.addEventListener("click", ev => { - highlight(); - ev.preventDefault(); - ev.stopPropagation(); - }) - } - - if (config.UNDO_WHEN_EDIT) { - document.querySelector("content").addEventListener("mousedown", ev => { - if (searcherList.length !== 0 && !ev.target.closest("#plugin-multi-highlighter")) { - clearHighlight(); - refreshFences(); - } - }, true) - } - - const handleHideElement = marker => { + const handleHiddenElement = marker => { const image = marker.closest(`span[md-inline="image"]`); if (image) { image.classList.add("md-expand"); @@ -343,19 +266,25 @@ } const scroll = marker => { - if (!marker) return; + const totalHeight = window.innerHeight || document.documentElement.clientHeight; + File.editor.focusAndRestorePos(); + File.editor.selection.scrollAdjust(marker, totalHeight / 2); + File.isFocusMode && File.editor.updateFocusMode(false); + } - handleHideElement(marker); + // 已废弃 + const scroll2 = marker => { requestAnimationFrame(() => marker.scrollIntoView({behavior: "smooth", block: "center", inline: "nearest"})); + } + const showIfNeed = marker => { if (config.SHOW_KEYWORD_OUTLINE) { document.querySelectorAll(".plugin-multi-highlighter-move").forEach(ele => ele.classList.remove("plugin-multi-highlighter-move")); marker.classList.add("plugin-multi-highlighter-move"); } if (config.SHOW_KEYWORD_BAR) { - const write = document.getElementById("write"); - const writeRect = write.getBoundingClientRect(); + const writeRect = entities.write.getBoundingClientRect(); const markerRect = marker.getBoundingClientRect(); const bar = document.createElement("div"); @@ -369,9 +298,62 @@ } } - const checkFilePath = () => getFilePath() === highlightFilePath; + const whichMarker = (fence, marker) => { + const markers = fence.getElementsByTagName("marker"); + for (let idx = 0; idx < markers.length; idx++) { + if (markers[idx] === marker) { + return idx + } + } + return -1 + } + + const getMarker = (fence, idx) => { + const markers = fence.querySelectorAll("marker"); + if (markers) { + return markers[idx]; + } + } + + entities.input.addEventListener("keydown", ev => { + if (ev.key === "Enter") { + ev.stopPropagation(); + ev.preventDefault(); + highlight(); + } else if (ev.key === "Escape") { + ev.stopPropagation(); + ev.preventDefault(); + clearHighlight(); + entities.modal.style.display = "none"; + } + }) + + entities.caseOption.addEventListener("click", ev => { + entities.caseOption.classList.toggle("select"); + config.CASE_SENSITIVE = !config.CASE_SENSITIVE; + ev.preventDefault(); + ev.stopPropagation(); + }) + + if (config.SHOW_RUN_BUTTON) { + entities.runButton.addEventListener("click", ev => { + highlight(); + ev.preventDefault(); + ev.stopPropagation(); + }) + } + + if (config.UNDO_WHEN_EDIT) { + document.querySelector("content").addEventListener("mousedown", ev => { + if (multiHighlighter.length() !== 0 && !ev.target.closest("#plugin-multi-highlighter")) { + clearHighlight(); + refreshFences(); + } + }, true) + } - modal.result.addEventListener("mousedown", ev => { + let markerIdx = -1; + entities.result.addEventListener("mousedown", ev => { const target = ev.target.closest(".plugin-multi-highlighter-result-item"); if (!target) return; @@ -379,7 +361,7 @@ ev.preventDefault(); // 当用户切换文档时 - if (!checkFilePath()) { + if (getFilePath() !== lastHighlightFilePath) { highlight(); return; } @@ -410,10 +392,19 @@ return; } - scroll(next); + const fence = next.closest("#write .md-fences"); + if (fence && !fence.classList.contains("modeLoaded")) { + // 接下来的工作交给File.editor.fences.addCodeBlock + markerIdx = whichMarker(fence, next); + scroll(next); + } else { + handleHiddenElement(next); + scroll(next); + showIfNeed(next); + } target.setAttribute("cur", nextIdx + ""); if (config.SHOW_CURRENT_INDEX) { - const searcher = searcherList[idx]; + const searcher = multiHighlighter.getHighlighter(idx); if (searcher) { target.innerText = `${searcher.token.text} (${nextIdx + 1}/${searcher.matches.length})` } @@ -421,10 +412,10 @@ }) if (config.ALLOW_DRAG) { - modal.input.addEventListener("mousedown", ev => { + entities.input.addEventListener("mousedown", ev => { if (!metaKeyPressed(ev) || ev.button !== 0) return; ev.stopPropagation(); - const rect = modal.modal.getBoundingClientRect(); + const rect = entities.modal.getBoundingClientRect(); const shiftX = ev.clientX - rect.left; const shiftY = ev.clientY - rect.top; @@ -433,8 +424,8 @@ ev.stopPropagation(); ev.preventDefault(); requestAnimationFrame(() => { - modal.modal.style.left = ev.clientX - shiftX + 'px'; - modal.modal.style.top = ev.clientY - shiftY + 'px'; + entities.modal.style.left = ev.clientX - shiftX + 'px'; + entities.modal.style.top = ev.clientY - shiftY + 'px'; }); } @@ -443,14 +434,77 @@ ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('mousemove', onMouseMove); - modal.modal.onmouseup = null; + entities.modal.onmouseup = null; } ) document.addEventListener('mousemove', onMouseMove); }) - modal.input.ondragstart = () => false + entities.input.ondragstart = () => false } + + const _timer = setInterval(() => { + if (!File || !File.editor || !File.editor.fences || !File.editor.fences.addCodeBlock) return; + clearInterval(_timer); + + let hasMarker; + const before = (...args) => { + const cid = args[0]; + if (!cid || multiHighlighter.length() === 0) return; + + const marker = entities.write.querySelector(`.md-fences[cid=${cid}] marker`); + hasMarker = !!marker; + } + + const decorator = (original, before, after) => { + return function () { + before.call(this, ...arguments); + const result = original.apply(this, arguments); + after.call(this, result, ...arguments); + return result; + }; + } + + const after = (result, ...args) => { + const cid = args[0]; + if (!cid || !hasMarker || multiHighlighter.length() === 0) return; + + hasMarker = false; + const fence = entities.write.querySelector(`.md-fences[cid=${cid}]`); + if (!fence) return; + + const tokens = multiHighlighter.getTokens(); + const fenceMultiHighlighter = new multiHighlighterClass(); + fenceMultiHighlighter.new(tokens, fence, config.CASE_SENSITIVE, "plugin-search-hit"); + fenceMultiHighlighter.highlight(); + fenceMultiHighlighterList.push(fenceMultiHighlighter); + + if (markerIdx !== -1) { + const nthMarker = getMarker(fence, markerIdx); + if (nthMarker) { + scroll(nthMarker); + showIfNeed(nthMarker); + } + markerIdx = -1; + } + } + File.editor.fences.addCodeBlock = decorator(File.editor.fences.addCodeBlock, before, after); + }, config.LOOP_DETECT_INTERVAL); + + const Call = () => { + entities.modal.style.display = "block"; + entities.input.select(); + } + + window.addEventListener("keydown", ev => { + if (config.HOTKEY(ev)) { + Call(); + ev.preventDefault(); + ev.stopPropagation(); + } + }); + + module.exports = {config, Call}; console.log("multi_highlighter.js had been injected"); })() \ No newline at end of file diff --git a/plugin/multi_highlighter/multi_highlighter.js b/plugin/multi_highlighter/multi_highlighter.js index 081a1bae..342c29ac 100644 --- a/plugin/multi_highlighter/multi_highlighter.js +++ b/plugin/multi_highlighter/multi_highlighter.js @@ -1,431 +1,50 @@ -const StateTransition = { - empty: 0, - valid: 1, - match: 2 -} - -/** - * Search and higlight results within the given html root - * Instanciate by giving it a `root` and a search `token` - * - * Here is what a SearchToken should look like - * SearchToken { - * text: string; - * className?: string; - * caseSensitive?: boolean; - * } - * - * @param root - the root html container to perform the search in - * @param token - a search token as described - * @param scrollToResult - whether or not selecting a result should scroll it into view - * @param defaultClassName - * @param defaultCaseSensitive - */ -class InstantSearch { - constructor( - root, - token, - scrollToResult = true, - defaultClassName = "highlight", - defaultCaseSensitive = false, - ) { - this.state = {} - this.root = root - this.token = token - this.scrollToResult = scrollToResult - this.defaultClassName = defaultClassName - this.defaultCaseSensitive = defaultCaseSensitive - this.matches = [] - this.perfs = [] - } - - /** - * Search and highlight occurrences of the current token in the current root - */ - highlight() { - this.matches = [] - this.state[this.token.text] = {} - if (this.token.text.length > 0) { - const t1 = performance.now() - this.walk(this.root) - const t2 = performance.now() - this.perfs.push({event: "Search text", time: t2 - t1}) - - // reverse so the previous match offset don't change when wrapping the result - const t3 = performance.now() - this.matches.reverse().forEach(m => { - const className = m.token.className || this.defaultClassName - const range = this.createRange(m.startNode, m.startOffset, m.endNode, m.endOffset) - this.wrapRange(range, className, m.startNode, m.endNode) - }) - const t4 = performance.now() - this.perfs.push({event: "Highlight text", time: t4 - t3}) - } - } - - /** - * Remove all highlights from the current root - */ - removeHighlight() { - const t1 = performance.now() - let element - if (this.root instanceof Element) { - element = this.root - } else if (this.root.parentElement) { - element = this.root.parentElement - } - const className = this.token.className || this.defaultClassName - element && element.querySelectorAll(`.${className}`).forEach( - el => { - const fragment = document.createDocumentFragment() - const childNodes = el.childNodes - fragment.append(...Array.from(childNodes)) - const parent = el.parentNode - parent && parent.replaceChild(fragment, el) - parent && parent.normalize() - this.mergeAdjacentSimilarNodes(parent) - } +const dirname = global.dirname || global.__dirname; +const filepath = reqnode('path').join(dirname, "plugin", "multi_highlighter", "highlighter.js"); +const {InstantSearch} = reqnode(filepath); + +class multiHighlighter { + constructor() { + this.highlighterList = [] + } + + _newHighlighter(root, key, caseSensitive, className) { + return new InstantSearch( + root, // root + {text: key, caseSensitive: caseSensitive, className: className}, //token + true, // scrollToResult + className, // defaultClassName + caseSensitive, // defaultCaseSensitive ) - const t2 = performance.now() - this.perfs.push({event: "Remove highlights", time: t2 - t1}) } - /** - * Merge adjacent nodes if they are instances of the same tag - * @param parent - */ - mergeAdjacentSimilarNodes(parent) { - if (parent && parent.childNodes) { - Array.from(parent.childNodes).reduce((acc, val) => { - if (val instanceof Element) { - if (acc && acc.tagName.toLowerCase() === val.tagName.toLowerCase()) { - acc.append(...Array.from(val.childNodes)) - parent.removeChild(val) - acc && this.mergeAdjacentSimilarNodes(acc) - } else { - acc && this.mergeAdjacentSimilarNodes(acc) - acc = val - } - } else { - acc && this.mergeAdjacentSimilarNodes(acc) - acc = undefined - } - return acc - }, undefined) - } + new(keyArr, root, caseSensitive, className) { + this.highlighterList = keyArr.map((key, idx) => this._newHighlighter(root, key, caseSensitive, className + idx)); } - /** - * Advance our state machine character by character in the given node - * @param node - */ - search(node) { - const text = node.textContent - const token = this.token - const state = this.state[token.text] - const caseSensitive = token.caseSensitive || this.defaultCaseSensitive - const tokenStr = caseSensitive ? token.text : token.text.toLowerCase() - - for (let i = 0; i < text.length;) { - const char = text[i] - const next = ( - `${state.current || ""}${caseSensitive ? char : char.toLowerCase()}` - .replace(/\s+/g, " ") - ) - if (next === tokenStr) { - this.transitionState(StateTransition.match, state, node, i, next) - i++ - } else { - const pos = tokenStr.indexOf(next) - if (pos === 0) { - this.transitionState(StateTransition.valid, state, node, i, next) - i++ - } else { - this.transitionState(StateTransition.empty, state, node, i, next) - if (next.length === 1) { - i++ - } - } - } - } - } - - /** - * Execute the given state transition and update the state machine output - * @param type - * @param state - * @param node - * @param index - * @param next - */ - transitionState(type, state, node, index, next) { - // let debug = `next: "${next}"` - switch (type) { - case StateTransition.empty: - // debug += " -> empty state" - this.resetState(state) - break - case StateTransition.valid: - // debug += " -> valid state" - if (!state.current || state.current.length === 0) { - state.startNode = node - state.startOffset = index - } - state.current = next - break - case StateTransition.match: { - const isSingleChar = this.token.text.length === 1 - const startNode = isSingleChar ? node : state.startNode - const startOffset = isSingleChar ? index : state.startOffset - this.matches.push({ - token: this.token, - startNode, - startOffset, - endNode: node, - endOffset: index + 1 - }) - // debug += ( - // `\n[Found match!]\n` - // + `startOffset: ${startOffset} - in "${startNode.textContent}"\n` - // + `endOffset: ${i + 1} - in "${node.textContent}"` - // ) - this.resetState(state) - break - } - default: - break - } - // console.log(debug) - } - - /** - * Create a return a range for the given arguments - * @param startNode - * @param startOffset - * @param endNode - * @param endOffset - */ - createRange(startNode, startOffset, endNode, endOffset) { - const range = new Range() - range.setStart(startNode, startOffset) - range.setEnd(endNode, endOffset) - return range - } - - /** - * Wrap a range with a with the given className - * @param range - * @param className - * @param startNode - * @param endNode - */ - wrapRange(range, className, startNode, endNode) { - const clonedStartNode = startNode.cloneNode(true) - const clonedEndNode = endNode.cloneNode(true) - const selectedText = range.extractContents() - const marker = document.createElement("marker") - marker.classList.add(className) - marker.appendChild(selectedText) - range.insertNode(marker) - this.removeEmptyDirectSiblings(marker, clonedStartNode, clonedEndNode) - } - - /** - * Remove any empty direct sibling before and after the element - * @param element - * @param clonedStartNode - * @param clonedEndNode - */ - removeEmptyDirectSiblings(element, clonedStartNode, clonedEndNode) { - const remove = (element, originalNode) => { - let keepRemoving = true - while (keepRemoving) { - keepRemoving = this.removeEmptyElement(element, originalNode) - } - } - remove(element.previousElementSibling, clonedStartNode) - remove(element.nextElementSibling, clonedEndNode) - } - - /** - * Remove any empty element that wasn't found in the original (before wrapping) node - * @param element - * @param originalNode - */ - removeEmptyElement(element, originalNode) { - const isInOriginalNode = (element) => originalNode.childNodes - && Array.from(originalNode.childNodes) - .some((c) => (c instanceof Element) && c.outerHTML === element.outerHTML) - if (element) { - if (element.parentNode && !isInOriginalNode(element) && !element.textContent) { - element.parentNode.removeChild(element) - return true - } else if (element.childNodes[0] === element.children[0]) { - return this.removeEmptyElement(element.children[0], originalNode) - } - } - return false - } - - /** - * Resets the state to be empty - * @param state - */ - resetState(state) { - delete state.current - delete state.startNode - delete state.startOffset - return state - } - - /** - * Walk through the current root TextNodes - * @param node - */ - walk(node) { - let currentParent = undefined - const treeWalker = document.createTreeWalker( - node, - NodeFilter.SHOW_TEXT - ) - - while (treeWalker.nextNode()) { - const current = treeWalker.currentNode - if (current.parentElement) { - const parent = current.parentElement - const display = getComputedStyle(parent).display - if ( - !["", "contents", "inline", "inline-block"].includes(display) - && currentParent !== parent - ) { - this.resetState(this.state[this.token.text]) - currentParent = parent - } - } - this.search(current) - } - } - - /** - * Get the current highlighted results elements collection, the current active one, - * and its corresponding index in the results collection - */ - getResultsElements() { - const className = this.token.className || this.defaultClassName - const results = this.root.querySelectorAll(`.${className}`) - const active = this.root.querySelector(`.${className}.active`) - const activeIndex = Array.from(results).findIndex(el => el === active) - return { - results, - active, - activeIndex - } - } - - /** - * Switch selected result from the current one to the next one, open any closed detail - * ancestor and scroll the next selected result into view should it not be visible already - * @param active - * @param next - * @param results - */ - switchSelected(active, next, results) { - const didOpenDetails = this.openDetailsAncestors(next) - if (didOpenDetails) { - this.resyncAnimations(results) - } - active && active.classList.remove("active") - next && next.classList.add("active") - if (this.scrollToResult) { - const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - console.log(entry) - if (entry.target === next && !entry.isIntersecting) { - console.log(entry.isIntersecting) - observer.unobserve(next) - observer.disconnect() - requestAnimationFrame( - () => next.scrollIntoView({block: "center", behavior: "smooth"}) - ) - } else { - observer.unobserve(entry.target) - } - } - }) - if (next) { - observer.observe(next) - } - } + highlight() { + this.highlighterList.forEach(highlighter => highlighter.highlight()); } - /** - * Open any closed detail ancestor to the given element - * @param element - */ - openDetailsAncestors(element) { - const detailsAncestors = this.getDetailsAncestors(element) - let didOpenDetails = false - detailsAncestors.forEach(d => { - if (!d.open && !d.children[0].contains(element)) { - d.open = true - didOpenDetails = true - } - }) - return didOpenDetails + clear() { + this.highlighterList.forEach(highlighter => highlighter.removeHighlight()); + this.highlighterList = []; } - /** - * Restart all the animations so they are in sync - * When toggling content (like, when opening details), we sometimes need this - */ - resyncAnimations(results) { - const className = this.token.className || this.defaultClassName - results.forEach(r => { - r.classList.remove(className) - requestAnimationFrame(() => r.classList.add(className)) - }) + length() { + return this.highlighterList.length } - /** - * Cycle through results and select the next one, or the first one when - * no result is currently selected - */ - selectNextResult() { - const {results, active, activeIndex} = this.getResultsElements() - const length = results.length - const index = (activeIndex + 1) % length - this.switchSelected(active, results[index], results) + getList() { + return this.highlighterList } - /** - * Cycle through results and select the previous one, or the last one when - * no result is currently selected - */ - selectPrevResult() { - const {results, active, activeIndex} = this.getResultsElements() - const length = results.length - const index = ((activeIndex > 0 ? activeIndex : length) - 1) - this.switchSelected(active, results[index], results) + getHighlighter(idx) { + return this.highlighterList[idx] } - /** - * Get all the
ancestors for a given element, including the element - * itself if it's a
element - */ - getDetailsAncestors(element) { - const details = [] - let current = element - while (current) { - if ( - current instanceof HTMLDetailsElement - ) { - details.push(current) - } - current = current.parentElement - } - return details + getTokens() { + return this.highlighterList.map(highlighter => highlighter.token.text) } - } -module.exports = {InstantSearch}; \ No newline at end of file +module.exports = {multiHighlighter};