Created
September 25, 2022 00:12
-
-
Save mrienstra/bbd9c68a8686643465e3efc3856906d8 to your computer and use it in GitHub Desktop.
Trimmed down heavily from https://github.com/shd101wyy/mume/blob/d1904b9/src/webview.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// preview controller | |
(() => { | |
interface MarkdownConfig { | |
scrollSync?: boolean; | |
} | |
class PreviewController { | |
/** | |
* Scroll map that maps buffer line to scrollTops of html elements | |
*/ | |
private scrollMap: number[] = null; | |
/** | |
* TextEditor total buffer line count | |
*/ | |
private totalLineCount: number = 0; | |
/** | |
* Used to delay preview scroll | |
*/ | |
private previewScrollDelay: number = 0; | |
/** | |
* SetTimeout value | |
*/ | |
private scrollTimeout: any = null; | |
/** | |
* This controller should be initialized when the html dom is loaded. | |
*/ | |
constructor() { | |
/** | |
* We need to tell the parent window that the preview is loaded, and the | |
* markdown needs to be updated so that we can update properties like | |
* `sidebarTOCHTML`, etc... | |
*/ | |
previewElement.onscroll = this.scrollEvent.bind(this); | |
} | |
/** | |
* init .sidebar-toc-btn | |
*/ | |
private initSideBarTOCButton() { | |
this.toolbar.sidebarTOCBtn.onclick = () => { | |
this.scrollMap = null; | |
}; | |
} | |
/** | |
* init .back-to-top-btn | |
*/ | |
private initBackToTopButton() { | |
this.toolbar.backToTopBtn.onclick = () => { | |
this.previewElement.scrollTop = 0; | |
}; | |
} | |
/** | |
* init contextmenu | |
* reference: http://jsfiddle.net/w33z4bo0/1/ | |
*/ | |
private initContextMenu() { | |
$["contextMenu"]({ | |
selector: ".preview-container", | |
items: { | |
sync_source: { | |
name: "Sync Source", | |
callback: () => this.previewSyncSource(), | |
}, | |
}, | |
}); | |
} | |
private setZoomLevel() { | |
this.scrollMap = null; | |
} | |
/** | |
* update previewElement innerHTML content | |
* @param html | |
*/ | |
private updateHTML(html: string, id: string, classes: string) { | |
this.previewScrollDelay = Date.now() + 500; | |
const scrollTop = this.previewElement.scrollTop; | |
// init several events | |
this.initEvents().then(() => { | |
this.scrollMap = null; | |
// scroll to initial position | |
if (!this.doneLoadingPreview) { | |
this.doneLoadingPreview = true; | |
this.scrollToRevealSourceLine(this.initialLine); | |
// clear @scrollMap after 2 seconds because sometimes | |
// loading images will change scrollHeight. | |
setTimeout(() => (this.scrollMap = null), 2000); | |
} else { | |
// restore scrollTop | |
this.previewElement.scrollTop = scrollTop; // <= This line is necessary... | |
} | |
}); | |
} | |
/** | |
* Build offsets for each line (lines can be wrapped) | |
* That's a bit dirty to process each line everytime, but ok for demo. | |
* Optimizations are required only for big texts. | |
* @return number[] | |
*/ | |
private buildScrollMap(): number[] { | |
if (!this.totalLineCount) { | |
return null; | |
} | |
const scrollMap = []; | |
const nonEmptyList = []; | |
for (let i = 0; i < this.totalLineCount; i++) { | |
scrollMap.push(-1); | |
} | |
nonEmptyList.push(0); | |
scrollMap[0] = 0; | |
// write down the offsetTop of element that has 'data-line' property to scrollMap | |
const lineElements = | |
this.previewElement.getElementsByClassName("sync-line"); | |
for (let i = 0; i < lineElements.length; i++) { | |
let el = lineElements[i] as HTMLElement; | |
let t: any = el.getAttribute("data-line"); | |
if (!t) { | |
continue; | |
} | |
t = parseInt(t, 10); | |
if (!t) { | |
continue; | |
} | |
// this is for ignoring footnote scroll match | |
if (t < nonEmptyList[nonEmptyList.length - 1]) { | |
el.removeAttribute("data-line"); | |
} else { | |
nonEmptyList.push(t); | |
let offsetTop = 0; | |
while (el && el !== this.previewElement) { | |
offsetTop += el.offsetTop; | |
el = el.offsetParent as HTMLElement; | |
} | |
scrollMap[t] = Math.round(offsetTop); | |
} | |
} | |
nonEmptyList.push(this.totalLineCount); | |
scrollMap.push(this.previewElement.scrollHeight); | |
let pos = 0; | |
for (let i = 0; i < this.totalLineCount; i++) { | |
if (scrollMap[i] !== -1) { | |
pos++; | |
continue; | |
} | |
const a = nonEmptyList[pos - 1]; | |
const b = nonEmptyList[pos]; | |
scrollMap[i] = Math.round( | |
(scrollMap[b] * (i - a) + scrollMap[a] * (b - i)) / (b - a) | |
); | |
} | |
return scrollMap; // scrollMap's length == screenLineCount (vscode can't get screenLineCount... sad) | |
} | |
private scrollEvent() { | |
if (!this.config.scrollSync) { | |
return; | |
} | |
if (!this.scrollMap) { | |
this.scrollMap = this.buildScrollMap(); | |
return; | |
} | |
if (Date.now() < this.previewScrollDelay) { | |
return; | |
} | |
this.previewSyncSource(); | |
} | |
private previewSyncSource() { | |
let scrollToLine; | |
if (this.previewElement.scrollTop === 0) { | |
scrollToLine = 0; | |
this.postMessage("revealLine", [this.sourceUri, scrollToLine]); | |
return; | |
} | |
const top = | |
this.previewElement.scrollTop + this.previewElement.offsetHeight / 2; | |
// try to find corresponding screen buffer row | |
if (!this.scrollMap) { | |
this.scrollMap = this.buildScrollMap(); | |
} | |
let i = 0; | |
let j = this.scrollMap.length - 1; | |
let count = 0; | |
let screenRow = -1; // the screenRow is the bufferRow in vscode. | |
let mid; | |
while (count < 20) { | |
if (Math.abs(top - this.scrollMap[i]) < 20) { | |
screenRow = i; | |
break; | |
} else if (Math.abs(top - this.scrollMap[j]) < 20) { | |
screenRow = j; | |
break; | |
} else { | |
mid = Math.floor((i + j) / 2); | |
if (top > this.scrollMap[mid]) { | |
i = mid; | |
} else { | |
j = mid; | |
} | |
} | |
count++; | |
} | |
if (screenRow === -1) { | |
screenRow = mid; | |
} | |
scrollToLine = screenRow; | |
this.postMessage("revealLine", [this.sourceUri, scrollToLine]); | |
} | |
/** | |
* scroll preview to match `line` | |
* @param line: the buffer row of editor | |
*/ | |
private scrollSyncToLine(line: number, topRatio: number = 0.372) { | |
if (!this.scrollMap) { | |
this.scrollMap = this.buildScrollMap(); | |
} | |
if (!this.scrollMap || line >= this.scrollMap.length) { | |
return; | |
} | |
if (line + 1 === this.totalLineCount) { | |
// last line | |
this.scrollToPos(this.previewElement.scrollHeight); | |
} else { | |
/** | |
* Since I am not able to access the viewport of the editor | |
* I used `golden section` (0.372) here for scrollTop. | |
*/ | |
this.scrollToPos( | |
Math.max( | |
this.scrollMap[line] - this.previewElement.offsetHeight * topRatio, | |
0 | |
) | |
); | |
} | |
} | |
/** | |
* Smoothly scroll the previewElement to `scrollTop` position. | |
* @param scrollTop: the scrollTop position that the previewElement should be at | |
*/ | |
private scrollToPos(scrollTop) { | |
if (this.scrollTimeout) { | |
clearTimeout(this.scrollTimeout); | |
this.scrollTimeout = null; | |
} | |
if (scrollTop < 0) { | |
return; | |
} | |
const delay = 10; | |
const helper = (duration = 0) => { | |
this.scrollTimeout = setTimeout(() => { | |
if (duration <= 0) { | |
this.previewScrollDelay = Date.now() + 500; | |
this.previewElement.scrollTop = scrollTop; | |
return; | |
} | |
const difference = scrollTop - this.previewElement.scrollTop; | |
const perTick = (difference / duration) * delay; | |
// disable preview onscroll | |
this.previewScrollDelay = Date.now() + 500; | |
this.previewElement.scrollTop += perTick; | |
if (this.previewElement.scrollTop === scrollTop) { | |
return; | |
} | |
helper(duration - delay); | |
}, delay); | |
}; | |
const scrollDuration = 120; | |
helper(scrollDuration); | |
} | |
/** | |
* It's unfortunate that I am not able to access the viewport. | |
* @param line | |
*/ | |
private scrollToRevealSourceLine(line, topRatio = 0.372) { | |
if (line === this.currentLine) { | |
return; | |
} else { | |
this.currentLine = line; | |
} | |
// disable preview onscroll | |
this.previewScrollDelay = Date.now() + 500; | |
this.scrollSyncToLine(line, topRatio); | |
} | |
/** | |
* Initialize several `window` events. | |
*/ | |
private initWindowEvents() { | |
/** | |
* Several keyboard events. | |
*/ | |
window.addEventListener("keydown", (event) => { | |
if (event.shiftKey && event.ctrlKey && event.which === 83) { | |
// ctrl+shift+s preview sync source | |
return this.previewSyncSource(); | |
} else if (event.metaKey || event.ctrlKey) { | |
if (event.which === 38) { | |
// [ArrowUp] scroll to the most top | |
this.previewElement.scrollTop = 0; | |
} | |
} | |
}); | |
window.addEventListener("resize", () => { | |
this.scrollMap = null; | |
}); | |
window.addEventListener( | |
"message", | |
(event) => { | |
const data = event.data; | |
if (data.command === "updateHTML") { | |
this.totalLineCount = data.totalLineCount; | |
this.sourceUri = data.sourceUri; | |
this.updateHTML(data.html, data.id, data.class); | |
} else if ( | |
data.command === "changeTextEditorSelection" && | |
(this.config.scrollSync || data.forced) | |
) { | |
const line = parseInt(data.line, 10); | |
let topRatio = parseFloat(data.topRatio); | |
if (isNaN(topRatio)) { | |
topRatio = 0.372; | |
} | |
this.scrollToRevealSourceLine(line, topRatio); | |
} else if (data.command === "previewSyncSource") { | |
this.previewSyncSource(); | |
} else if (data.command === "scrollPreviewToTop") { | |
this.previewElement.scrollTop = 0; | |
} | |
}, | |
false | |
); | |
} | |
/* End of PreviewController class */ | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment