Skip to content

Instantly share code, notes, and snippets.

@mrienstra
Created September 25, 2022 00:12
Show Gist options
  • Save mrienstra/bbd9c68a8686643465e3efc3856906d8 to your computer and use it in GitHub Desktop.
Save mrienstra/bbd9c68a8686643465e3efc3856906d8 to your computer and use it in GitHub Desktop.
// 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