| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import shim from '@joplin/lib/shim'; | ||
|
|
||
| // SyncScrollMap is used for synchronous scrolling between Markdown Editor and Viewer. | ||
| // It has the mapping information between the line numbers of a Markdown text and | ||
| // the scroll positions (percents) of the elements in the HTML document transformed | ||
| // from the Markdown text. | ||
| // To see the detail of synchronous scrolling, refer the following design document. | ||
| // https://github.com/laurent22/joplin/pull/5512#issuecomment-931277022 | ||
|
|
||
| export interface SyncScrollMap { | ||
| line: number[]; | ||
| percent: number[]; | ||
| viewHeight: number; | ||
| } | ||
|
|
||
| // Map creation utility class | ||
| export class SyncScrollMapper { | ||
laurent22 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| private map_: SyncScrollMap = null; | ||
| private refreshTimeoutId_: any = null; | ||
| private refreshTime_ = 0; | ||
|
|
||
| // Invalidates an outdated SyncScrollMap. | ||
| // For a performance reason, too frequent refresh requests are | ||
| // skippend and delayed. If forced is true, refreshing is immediately performed. | ||
| public refresh(forced: boolean) { | ||
| const elapsed = this.refreshTime_ ? Date.now() - this.refreshTime_ : 10 * 1000; | ||
| if (!forced && (elapsed < 200 || this.refreshTimeoutId_)) { | ||
| // to avoid too frequent recreations of a sync-scroll map. | ||
| if (this.refreshTimeoutId_) { | ||
| shim.clearTimeout(this.refreshTimeoutId_); | ||
| this.refreshTimeoutId_ = null; | ||
| } | ||
| this.refreshTimeoutId_ = shim.setTimeout(() => { | ||
| this.refreshTimeoutId_ = null; | ||
| this.map_ = null; | ||
| this.refreshTime_ = Date.now(); | ||
| }, 200); | ||
| } else { | ||
| this.map_ = null; | ||
| this.refreshTime_ = Date.now(); | ||
| } | ||
| } | ||
|
|
||
| // Creates a new SyncScrollMap or reuses an existing one. | ||
| public get(doc: Document): SyncScrollMap { | ||
| // Returns a cached translation map between editor's scroll percenet | ||
| // and viewer's scroll percent. Both attributes (line and percent) of | ||
| // the returned map are sorted respectively. | ||
| // Since creating this map is costly for each scroll event, it is cached. | ||
| // When some update events which outdate it such as switching a note or | ||
| // editing a note, it has to be invalidated (using refresh()), | ||
| // and a new map will be created at a next scroll event. | ||
| if (!doc) return null; | ||
| const contentElement = doc.getElementById('joplin-container-content'); | ||
laurent22 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (!contentElement) return null; | ||
| const height = Math.max(1, contentElement.scrollHeight - contentElement.clientHeight); | ||
| if (this.map_) { | ||
| // check whether map_ is obsolete | ||
| if (this.map_.viewHeight === height) return this.map_; | ||
| this.map_ = null; | ||
| } | ||
| // Since getBoundingClientRect() returns a relative position, | ||
| // the offset of the origin is needed to get its aboslute position. | ||
| const offset = doc.getElementById('rendered-md').getBoundingClientRect().top; | ||
laurent22 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (!offset) return null; | ||
| // Mapping information between editor's lines and viewer's elements is | ||
| // embedded into elements by the renderer. | ||
| // See also renderer/MdToHtml/rules/source_map.ts. | ||
| const elems = doc.getElementsByClassName('maps-to-line'); | ||
| const map: SyncScrollMap = { line: [0], percent: [0], viewHeight: height }; | ||
| // Each map entry is total-ordered. | ||
| let last = 0; | ||
| for (let i = 0; i < elems.length; i++) { | ||
| const top = elems[i].getBoundingClientRect().top - offset; | ||
| const line = Number(elems[i].getAttribute('source-line')); | ||
| const percent = Math.max(0, Math.min(1, top / height)); | ||
| if (map.line[last] < line && map.percent[last] < percent) { | ||
| map.line.push(line); | ||
| map.percent.push(percent); | ||
| last += 1; | ||
| } | ||
| } | ||
| if (map.percent[last] < 1) { | ||
| map.line.push(1e10); | ||
| map.percent.push(1); | ||
| } else { | ||
| map.line[last] = 1e10; | ||
| } | ||
| this.map_ = map; | ||
| return map; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| export default { | ||
| plugin: (markdownIt: any, params: any) => { | ||
|
|
||
| if (!params.mapsToLine) return; | ||
|
|
||
| const allowedLevels = { | ||
| paragraph_open: 0, | ||
| heading_open: 0, | ||
| // fence: 0, // fence uses custom rendering that doesn't propogate attr so it can't be used for now | ||
| blockquote_open: 0, | ||
| table_open: 0, | ||
| code_block: 0, | ||
| hr: 0, | ||
| html_block: 0, | ||
| list_item_open: 99, // this will stop matching if a list goes more than 99 indents deep | ||
| math_block: 0, | ||
| }; | ||
|
|
||
| for (const [key, allowedLevel] of Object.entries(allowedLevels)) { | ||
| const precedentRule = markdownIt.renderer.rules[key]; | ||
|
|
||
| markdownIt.renderer.rules[key] = (tokens: any[], idx: number, options: any, env: any, self: any) => { | ||
| if (!!tokens[idx].map && tokens[idx].level <= allowedLevel) { | ||
| const line = tokens[idx].map[0]; | ||
| tokens[idx].attrJoin('class', 'maps-to-line'); | ||
| tokens[idx].attrSet('source-line', `${line}`); | ||
| } | ||
| if (precedentRule) { | ||
| return precedentRule(tokens, idx, options, env, self); | ||
| } else { | ||
| return self.renderToken(tokens, idx, options); | ||
| } | ||
| }; | ||
| } | ||
| }, | ||
| }; |