diff options
Diffstat (limited to 'src/fossil.diff.js')
| -rw-r--r-- | src/fossil.diff.js | 817 |
1 files changed, 817 insertions, 0 deletions
diff --git a/src/fossil.diff.js b/src/fossil.diff.js new file mode 100644 index 0000000..4c3effc --- /dev/null +++ b/src/fossil.diff.js @@ -0,0 +1,817 @@ +/** + diff-related JS APIs for fossil. +*/ +"use strict"; +/* Locate the UI element (if any) into which we can inject some diff-related + UI controls. */ +window.fossil.onPageLoad(function(){ + const potentialParents = window.fossil.page.diffControlContainers = [ + /* CSS selectors for possible parents for injected diff-related UI + controls. */ + /* Put the most likely pages at the end, as array.pop() is more + efficient than array.shift() (see loop below). */ + /* /filedit */ 'body.cpage-fileedit #fileedit-tab-diff-buttons', + /* /wikiedit */ 'body.cpage-wikiedit #wikiedit-tab-diff-buttons', + /* /fdiff */ 'body.fdiff form div.submenu', + /* /vdiff */ 'body.vdiff form div.submenu', + /* /info, /vinfo, /ckout */ 'body.vinfo div.sectionmenu.info-changes-menu' + ]; + window.fossil.page.diffControlContainer = undefined; + while( potentialParents.length ){ + if( (window.fossil.page.diffControlContainer + = document.querySelector(potentialParents.pop())) ){ + break; + } + } +}); + +window.fossil.onPageLoad(function(){ + /** + Adds toggle checkboxes to each file entry in the diff views for + /info and similar pages. + */ + if( !window.fossil.page.diffControlContainer ){ + return; + } + const D = window.fossil.dom; + const allToggles = [/*collection of all diff-toggle checkboxes*/]; + let checkedCount = + 0 /* When showing more than one diff, keep track of how many + "show/hide" checkboxes are checked so we can update the + "show/hide all" label dynamically. */; + let btnAll /* UI control to show/hide all diffs */; + /* Install a diff-toggle button for the given diff table element. */ + const addToggle = function(diffElem){ + const sib = diffElem.previousElementSibling, + ckbox = sib ? D.addClass(D.checkbox(true), 'diff-toggle') : 0; + if(!sib) return; + const lblToggle = D.label(); + D.append(lblToggle, ckbox, D.text(" show/hide ")); + allToggles.push(ckbox); + ++checkedCount; + /* Make all of the available empty space a click zone for the checkbox */ + lblToggle.style.flexGrow = 1; + lblToggle.style.textAlign = 'right'; + D.append(sib, lblToggle); + ckbox.addEventListener('change', function(){ + diffElem.classList[this.checked ? 'remove' : 'add']('hidden'); + if(btnAll){ + checkedCount += (this.checked ? 1 : -1); + btnAll.innerText = (checkedCount < allToggles.length) + ? "Show diffs" : "Hide diffs"; + } + }, false); + /* Extend the toggle click zone to all of the non-hyperlink + elements in the left of this area (filenames and hashes). */ + sib.firstElementChild.addEventListener('click', (event)=>{ + if( event.target===sib.firstElementChild ){ + /* Don't respond to clicks bubbling via hyperlink children */ + ckbox.click();; + } + }, false); + }; + if( !document.querySelector('body.fdiff') ){ + /* Don't show the diff toggle button for /fdiff because it only + has a single file to show (and also a different DOM layout). */ + document.querySelectorAll('table.diff').forEach(addToggle); + } + /** + Set up a "toggle all diffs" button which toggles all of the + above-installed checkboxes, but only if more than one diff is + rendered. + */ + const icm = allToggles.length>1 ? window.fossil.page.diffControlContainer : 0; + if(icm) { + btnAll = D.addClass(D.a("#", "Hide diffs"), "button"); + D.append( icm, btnAll ); + btnAll.addEventListener('click', function(ev){ + ev.preventDefault(); + ev.stopPropagation(); + const show = checkedCount < allToggles.length; + for( const ckbox of allToggles ){ + /* Toggle all entries to match this new state. We use click() + instead of ckbox.checked=... so that the on-change event handler + fires. */ + if(ckbox.checked!==show) ckbox.click(); + } + }, false); + } +}); + +window.fossil.onPageLoad(function(){ + const F = window.fossil, D = F.dom; + const Diff = F.diff = { + e:{/*certain cached DOM elements*/}, + config: { + chunkLoadLines: ( + F.config.diffContextLines * 3 + /*per /chat discussion*/ + ) || 20, + chunkFetch: { + /* Default callback handlers for Diff.fetchArtifactChunk(), + unless overridden by options passeed to that function. */ + beforesend: function(){}, + aftersend: function(){}, + onerror: function(e){ + console.error("XHR error: ",e); + } + } + } + }; + /** + Uses the /jchunk AJAX route to fetch specific lines of a given + artifact. The argument must be an Object suitable for passing as + the second argument to fossil.fetch(). Its urlParams property + must be an object with these properties: + + { + name: full hash of the target file, + from: first 1-based line number of the file to fetch (inclusive), + to: last 1-based line number of the file to fetch (inclusive) + } + + The fetchOpt object is NOT cloned for use by the call: it is used + as-is and may be modified by this call. Thus callers "really + should" pass a temporary object, not a long-lived one. + + If fetchOpt does not define any of the (beforesend, aftersend, + onerror) callbacks, the defaults from fossil.diff.config.chunkFetch + are used, so any given client page may override those to provide + page-level default handling. + + Note that onload callback is ostensibly optional but this + function is not of much use without an onload + handler. Conversely, the default onerror handler is often + customized on a per-page basis to send the error output somewhere + where the user can see it. + + The response, on success, will be an array of strings, each entry + being one line from the requested artifact. If the 'to' line is + greater than the length of the file, the array will be shorter + than (to-from) lines. + + The /jchunk route reports errors via JSON objects with + an "error" string property describing the problem. + + This is an async operation. Returns the fossil object. + */ + Diff.fetchArtifactChunk = function(fetchOpt){ + if(!fetchOpt.beforesend) fetchOpt.beforesend = Diff.config.chunkFetch.beforesend; + if(!fetchOpt.aftersend) fetchOpt.aftersend = Diff.config.chunkFetch.aftersend; + if(!fetchOpt.onerror) fetchOpt.onerror = Diff.config.chunkFetch.onerror; + fetchOpt.responseType = 'json'; + return F.fetch('jchunk', fetchOpt); + }; + + + /** + Extracts either the starting or ending line number from a + line-numer column in the given tr. isSplit must be true if tr + represents a split diff, else false. Expects its tr to be valid: + GIGO applies. Returns the starting line number if getStart, else + the ending line number. Returns the line number from the LHS file + if getLHS is true, else the RHS. + */ + const extractLineNo = function f(getLHS, getStart, tr, isSplit){ + if(!f.rx){ + f.rx = { + start: /^\s*(\d+)/, + end: /(\d+)\n?$/ + } + } + const td = tr.querySelector('td:nth-child('+( + /* TD element with the line numbers */ + getLHS ? 1 : (isSplit ? 4 : 2) + )+')'); + const m = f.rx[getStart ? 'start' : 'end'].exec(td.innerText); + return m ? +m[1] : undefined/*"shouldn't happen"*/; + }; + + /** + Installs chunk-loading controls into TR.diffskip element tr. + Each instance corresponds to a single TR.diffskip element. + + The goal is to base these controls roughly on github's, a good + example of which, for use as a model, is: + + https://github.com/msteveb/autosetup/commit/235925e914a52a542 + */ + const ChunkLoadControls = function(tr){ + this.$fetchQueue = []; + this.e = {/*DOM elements*/ + tr: tr, + table: tr.parentElement/*TBODY*/.parentElement + }; + this.isSplit = this.e.table.classList.contains('splitdiff')/*else udiff*/; + this.fileHash = this.e.table.dataset.lefthash; + tr.$chunker = this /* keep GC from reaping this */; + this.pos = { + /* These line numbers correspond to the LHS file. Because the + contents are common to both sides, we have the same number + for the RHS, but need to extract those line numbers from the + neighboring TR blocks */ + startLhs: +tr.dataset.startln, + endLhs: +tr.dataset.endln + }; + D.clearElement(tr); + this.e.td = D.addClass( + /* Holder for our UI controls */ + D.attr(D.td(tr), 'colspan', this.isSplit ? 5 : 4), + 'chunkctrl' + ); + this.e.msgWidget = D.addClass(D.span(), 'hidden'); + this.e.btnWrapper = D.div(); + D.append(this.e.td, this.e.btnWrapper); + /** + Depending on various factors, we need one or more of: + + - A single button to load the initial chunk incrementally + + - A single button to load all lines then remove this control + + - Two buttons: one to load upwards, one to load downwards + + - A single button to load the final chunk incrementally + */ + if(tr.nextElementSibling){ + this.pos.next = { + startLhs: extractLineNo(true, true, tr.nextElementSibling, this.isSplit), + startRhs: extractLineNo(false, true, tr.nextElementSibling, this.isSplit) + }; + } + if(tr.previousElementSibling){ + this.pos.prev = { + endLhs: extractLineNo(true, false, tr.previousElementSibling, this.isSplit), + endRhs: extractLineNo(false, false, tr.previousElementSibling, this.isSplit) + }; + } + let btnUp = false, btnDown = false; + /** + this.pos.next refers to the line numbers in the next TR's chunk. + this.pos.prev refers to the line numbers in the previous TR's chunk. + this.pos corresponds to the line numbers of the gap. + */ + if(this.pos.prev && this.pos.next + && ((this.pos.endLhs - this.pos.startLhs) + <= Diff.config.chunkLoadLines)){ + /* Place a single button to load the whole block, rather + than separate up/down buttons. */ + btnDown = false; + btnUp = this.createButton(this.FetchType.FillGap); + }else{ + /* Figure out which chunk-load buttons to add... */ + if(this.pos.prev){ + btnDown = this.createButton(this.FetchType.PrevDown); + } + if(this.pos.next){ + btnUp = this.createButton(this.FetchType.NextUp); + } + } + //this.e.btnUp = btnUp; + //this.e.btnDown = btnDown; + if(btnUp) D.append(this.e.btnWrapper, btnUp); + if(btnDown) D.append(this.e.btnWrapper, btnDown); + D.append(this.e.btnWrapper, this.e.msgWidget); + /* For debugging only... */ + this.e.posState = D.span(); + D.append(this.e.btnWrapper, this.e.posState); + this.updatePosDebug(); + }; + + ChunkLoadControls.prototype = { + /** An "enum" of values describing the types of context + fetches/operations performed by this type. The values in this + object must not be changed without modifying all logic which + relies on their relative order. */ + FetchType:{ + /** Append context to the bottom of the previous diff chunk. */ + PrevDown: 1, + /** Fill a complete gap between the previous/next diff chunks + or at the start of the next chunk or end of the previous + chunks. */ + FillGap: 0, + /** Prepend context to the start of the next diff chunk. */ + NextUp: -1, + /** Process the next queued action. */ + ProcessQueue: 0x7fffffff + }, + + /** + Creates and returns a button element for fetching a chunk in + the given fetchType (as documented for fetchChunk()). + */ + createButton: function(fetchType){ + let b; + switch(fetchType){ + case this.FetchType.PrevDown: + b = D.append( + D.addClass(D.span(), 'down'), + D.span(/*glyph holder*/) + ); + break; + case this.FetchType.FillGap: + b = D.append( + D.addClass(D.span(), 'up', 'down'), + D.span(/*glyph holder*/) + ); + break; + case this.FetchType.NextUp: + b = D.append( + D.addClass(D.span(), 'up'), + D.span(/*glyph holder*/) + ); + break; + default: + throw new Error("Internal API misuse: unexpected fetchType value "+fetchType); + } + D.addClass(b, 'jcbutton'); + b.addEventListener('click', ()=>this.fetchChunk(fetchType),false); + return b; + }, + + updatePosDebug: function(){ + if(this.e.posState){ + D.clearElement(this.e.posState); + //D.append(D.clearElement(this.e.posState), JSON.stringify(this.pos)); + } + return this; + }, + + /* Attempt to clean up resources and remove some circular references to + that GC can do the right thing. */ + destroy: function(){ + delete this.$fetchQueue; + D.remove(this.e.tr); + delete this.e.tr.$chunker; + delete this.e.tr; + delete this.e; + delete this.pos; + }, + + /** + If the gap between this.pos.endLhs/startLhs is less than or equal to + Diff.config.chunkLoadLines then this function replaces any up/down buttons + with a gap-filler button, else it's a no-op. Returns this object. + + As a special case, do not apply this at the start or bottom + of the diff, only between two diff chunks. + */ + maybeReplaceButtons: function(){ + if(this.pos.next && this.pos.prev + && (this.pos.endLhs - this.pos.startLhs <= Diff.config.chunkLoadLines)){ + D.clearElement(this.e.btnWrapper); + D.append(this.e.btnWrapper, this.createButton(this.FetchType.FillGap)); + if( this.$fetchQueue && this.$fetchQueue.length>1 ){ + this.$fetchQueue[1] = this.FetchType.FillGap; + this.$fetchQueue.length = 2; + } + } + return this; + }, + + /** + Callback for /jchunk responses. + */ + injectResponse: function f(fetchType/*as for fetchChunk()*/, + urlParam/*from fetchChunk()*/, + lines/*response lines*/){ + if(!lines.length){ + /* No more data to load */ + this.destroy(); + return this; + } + this.msg(false); + //console.debug("Loaded line range ", + //urlParam.from,"-",urlParam.to, "fetchType ",fetchType); + const lineno = [], + trPrev = this.e.tr.previousElementSibling, + trNext = this.e.tr.nextElementSibling, + doAppend = ( + !!trPrev && fetchType>=this.FetchType.FillGap + ) /* true to append to previous TR, else prepend to NEXT TR */; + const tr = doAppend ? trPrev : trNext; + const joinTr = ( + this.FetchType.FillGap===fetchType && trPrev && trNext + ) ? trNext : false + /* Truthy if we want to combine trPrev, the new content, and + trNext into trPrev and then remove trNext. */; + let i, td; + if(!f.convertLines){ + /* Reminder: string.replaceAll() is a relatively new + JS feature, not available in some still-widely-used + browser versions. */ + f.rx = [[/&/g, '&'], [/</g, '<']]; + f.convertLines = function(li){ + var s = li.join('\n'); + f.rx.forEach((a)=>s=s.replace(a[0],a[1])); + return s + '\n'; + }; + } + if(1){ // LHS line numbers... + const selector = '.difflnl > pre'; + td = tr.querySelector(selector); + const lnTo = Math.min(urlParam.to, + urlParam.from + + lines.length - 1/*b/c request range is inclusive*/); + for( i = urlParam.from; i <= lnTo; ++i ){ + lineno.push(i); + } + const lineNoTxt = lineno.join('\n')+'\n'; + const content = [td.innerHTML]; + if(doAppend) content.push(lineNoTxt); + else content.unshift(lineNoTxt); + if(joinTr){ + content.push(trNext.querySelector(selector).innerHTML); + } + td.innerHTML = content.join(''); + } + + if(1){// code block(s)... + const selector = '.difftxt > pre'; + td = tr.querySelectorAll(selector); + const code = f.convertLines(lines); + let joinNdx = 0/*selector[X] index to join together*/; + td.forEach(function(e){ + const content = [e.innerHTML]; + if(doAppend) content.push(code); + else content.unshift(code); + if(joinTr){ + content.push(trNext.querySelectorAll(selector)[joinNdx++].innerHTML) + } + e.innerHTML = content.join(''); + }); + } + + if(1){// Add blank lines in (.diffsep>pre) + const selector = '.diffsep > pre'; + td = tr.querySelector(selector); + for(i = 0; i < lineno.length; ++i) lineno[i] = ''; + const blanks = lineno.join('\n')+'\n'; + const content = [td.innerHTML]; + if(doAppend) content.push(blanks); + else content.unshift(blanks); + if(joinTr){ + content.push(trNext.querySelector(selector).innerHTML); + } + td.innerHTML = content.join(''); + } + + if(this.FetchType.FillGap===fetchType){ + /* Closing the whole gap between two chunks or a whole gap + at the start or end of a diff. */ + // RHS line numbers... + let startLnR = this.pos.prev + ? this.pos.prev.endRhs+1 /* Closing the whole gap between two chunks + or end-of-file gap. */ + : this.pos.next.startRhs - lines.length /* start-of-file gap */; + lineno.length = lines.length; + for( i = startLnR; i < startLnR + lines.length; ++i ){ + lineno[i-startLnR] = i; + } + const selector = '.difflnr > pre'; + td = tr.querySelector(selector); + const lineNoTxt = lineno.join('\n')+'\n'; + lineno.length = 0; + const content = [td.innerHTML]; + if(doAppend) content.push(lineNoTxt); + else content.unshift(lineNoTxt); + if(joinTr){ + content.push(trNext.querySelector(selector).innerHTML); + } + td.innerHTML = content.join(''); + if(joinTr) D.remove(joinTr); + this.destroy(); + return this; + }else if(this.FetchType.PrevDown===fetchType){ + /* Append context to previous TR. */ + // RHS line numbers... + let startLnR = this.pos.prev.endRhs+1; + lineno.length = lines.length; + for( i = startLnR; i < startLnR + lines.length; ++i ){ + lineno[i-startLnR] = i; + } + this.pos.startLhs += lines.length; + this.pos.prev.endRhs += lines.length; + this.pos.prev.endLhs += lines.length; + const selector = '.difflnr > pre'; + td = tr.querySelector(selector); + const lineNoTxt = lineno.join('\n')+'\n'; + lineno.length = 0; + const content = [td.innerHTML]; + if(doAppend) content.push(lineNoTxt); + else content.unshift(lineNoTxt); + td.innerHTML = content.join(''); + if(lines.length < (urlParam.to - urlParam.from)){ + /* No more data. */ + this.destroy(); + }else{ + this.maybeReplaceButtons(); + this.updatePosDebug(); + } + return this; + }else if(this.FetchType.NextUp===fetchType){ + /* Prepend content to next TR. */ + // RHS line numbers... + if(doAppend){ + throw new Error("Internal precondition violation: doAppend is true."); + } + let startLnR = this.pos.next.startRhs - lines.length; + lineno.length = lines.length; + for( i = startLnR; i < startLnR + lines.length; ++i ){ + lineno[i-startLnR] = i; + } + this.pos.endLhs -= lines.length; + this.pos.next.startRhs -= lines.length; + this.pos.next.startLhs -= lines.length; + const selector = '.difflnr > pre'; + td = tr.querySelector(selector); + const lineNoTxt = lineno.join('\n')+'\n'; + lineno.length = 0; + td.innerHTML = lineNoTxt + td.innerHTML; + if(this.pos.endLhs<1 + || lines.length < (urlParam.to - urlParam.from)){ + /* No more data. */ + this.destroy(); + }else{ + this.maybeReplaceButtons(); + this.updatePosDebug(); + } + return this; + }else{ + throw new Error("Unexpected 'fetchType' value."); + } + }, + + /** + Sets this widget's message to the given text. If the message + represents an error, the first argument must be truthy, else it + must be falsy. Returns this object. + */ + msg: function(isError,txt){ + if(txt){ + if(isError) D.addClass(this.e.msgWidget, 'error'); + else D.removeClass(this.e.msgWidget, 'error'); + D.append( + D.removeClass(D.clearElement(this.e.msgWidget), 'hidden'), + txt); + }else{ + D.addClass(D.clearElement(this.e.msgWidget), 'hidden'); + } + return this; + }, + + /** + Fetches and inserts a line chunk. fetchType is: + + this.FetchType.NextUp = upwards from next chunk (this.pos.next) + + this.FetchType.FillGap = the whole gap between this.pos.prev + and this.pos.next, or the whole gap before/after the + initial/final chunk in the diff. + + this.FetchType.PrevDown = downwards from the previous chunk + (this.pos.prev) + + Those values are set at the time this object is initialized but + one instance of this class may have 2 buttons, one each for + fetchTypes NextUp and PrevDown. + + This is an async operation. While it is in transit, any calls + to this function will have no effect except (possibly) to emit + a warning. Returns this object. + */ + fetchChunk: function(fetchType){ + if( !this.$fetchQueue ) return this; // HACKHACK: are we destroyed? + if( fetchType==this.FetchType.ProcessQueue ){ + this.$fetchQueue.shift(); + if( this.$fetchQueue.length==0 ) return this; + //console.log('fetchChunk: processing queue ...'); + } + else{ + this.$fetchQueue.push(fetchType); + if( this.$fetchQueue.length!=1 ) return this; + //console.log('fetchChunk: processing user input ...'); + } + fetchType = this.$fetchQueue[0]; + if( fetchType==this.FetchType.ProcessQueue ){ + /* Unexpected! Clear queue so recovery (manual restart) is possible. */ + this.$fetchQueue.length = 0; + return this; + } + /* Forewarning, this is a bit confusing: when fetching the + previous lines, we're doing so on behalf of the *next* diff + chunk (this.pos.next), and vice versa. */ + if(fetchType===this.FetchType.NextUp && !this.pos.next + || fetchType===this.FetchType.PrevDown && !this.pos.prev){ + console.error("Attempt to fetch diff lines but don't have any."); + return this; + } + this.msg(false,"Fetching diff chunk..."); + const self = this; + const fOpt = { + urlParams:{ + name: this.fileHash, from: 0, to: 0 + }, + aftersend: ()=>this.msg(false), + onload: function(list){ + self.injectResponse(fetchType,up,list); + if( !self.$fetchQueue || self.$fetchQueue.length==0 ) return; + /* Keep queue length > 0, or clicks stalled during (unusually lengthy) + injectResponse() may sneak in as soon as setTimeout() allows, find + an empty queue, and therefore start over with queue processing. */ + self.$fetchQueue[0] = self.FetchType.ProcessQueue; + setTimeout(self.fetchChunk.bind(self,self.FetchType.ProcessQueue)); + } + }; + const up = fOpt.urlParams; + if(fetchType===this.FetchType.FillGap){ + /* Easiest case: filling a whole gap. */ + up.from = this.pos.startLhs; + up.to = this.pos.endLhs; + }else if(this.FetchType.PrevDown===fetchType){ + /* Append to previous TR. */ + if(!this.pos.prev){ + console.error("Attempt to fetch next diff lines but don't have any."); + return this; + } + up.from = this.pos.prev.endLhs + 1; + up.to = up.from + + Diff.config.chunkLoadLines - 1/*b/c request range is inclusive*/; + if( this.pos.next && this.pos.next.startLhs <= up.to ){ + up.to = this.pos.next.startLhs - 1; + fetchType = this.FetchType.FillGap; + } + }else{ + /* Prepend to next TR */ + if(!this.pos.next){ + console.error("Attempt to fetch previous diff lines but don't have any."); + return this; + } + up.to = this.pos.next.startLhs - 1; + up.from = Math.max(1, up.to - Diff.config.chunkLoadLines + 1); + if( this.pos.prev && this.pos.prev.endLhs >= up.from ){ + up.from = this.pos.prev.endLhs + 1; + fetchType = this.FetchType.FillGap; + } + } + //console.debug("fetchChunk(",fetchType,")",up); + fOpt.onerror = function(err){ + if(self.e/*guard against a late-stage onerror() call*/){ + self.msg(true,err.message); + self.$fetchQueue.length = 0; + }else{ + Diff.config.chunkFetch.onerror.call(this,err); + } + }; + Diff.fetchArtifactChunk(fOpt); + return this; + } + }; + + /** + Adds context-loading buttons to one or more tables. The argument + may be a forEach-capable list of diff table elements, a query + selector string matching 0 or more diff tables, or falsy, in + which case all relevant diff tables are set up. It tags each + table it processes to that it will not be processed multiple + times by subsequent calls to this function. + + Note that this only works for diffs which have been marked up + with certain state, namely table.dataset.lefthash and TR + entries which hold state related to browsing context. + */ + Diff.setupDiffContextLoad = function(tables){ + if('string'===typeof tables){ + tables = document.querySelectorAll(tables); + }else if(!tables){ + tables = document.querySelectorAll('table.diff[data-lefthash]:not(.diffskipped)'); + } + /* Potential performance-related TODO: instead of installing all + of these at once, install them as the corresponding TR is + scrolled into view. */ + tables.forEach(function(table){ + if(table.classList.contains('diffskipped') || !table.dataset.lefthash) return; + D.addClass(table, 'diffskipped'/*avoid processing these more than once */); + table.querySelectorAll('tr.diffskip[data-startln]').forEach(function(tr){ + new ChunkLoadControls(D.addClass(tr, 'jchunk')); + }); + }); + return F; + }; + Diff.setupDiffContextLoad(); +}); +/* +** For a side-by-side diff, ensure that horizontal scrolling of either +** side of the diff is synchronized with the other side. +*/ +window.fossil.onPageLoad(function(){ + const F = window.fossil, D = F.dom, Diff = F.diff; + + /* Look for a parent element to hold the sbs-sync-scroll toggle + checkbox. This differs per page. If we don't find one, simply + elide that toggle and use whatever preference the user last + specified (defaulting to on). */ + let cbSync /* scroll-sync checkbox */; + let eToggleParent = /* element to put the sync-scroll checkbox in */ + document.querySelector('table.diff.splitdiff') + ? window.fossil.page.diffControlContainer + : undefined; + const keySbsScroll = 'sync-diff-scroll' /* F.storage key for persistent user preference */; + if( eToggleParent ){ + /* Add a checkbox to toggle sbs scroll sync. Remember that in + order to be UI-consistent in the /vdiff page we have to ensure + that the checkbox is to the LEFT of its label. We store the + sync-scroll preference in F.storage (not a cookie) so that it + persists across page loads and different apps. */ + cbSync = D.checkbox(keySbsScroll, F.storage.getBool(keySbsScroll,true)); + D.append(eToggleParent, D.append( + D.addClass(D.create('span'), 'input-with-label'), + D.append(D.create('label'), + cbSync, "Scroll Sync") + )); + cbSync.addEventListener('change', function(e){ + F.storage.set(keySbsScroll, e.target.checked); + }); + } + const useSync = cbSync ? ()=>cbSync.checked : ()=>F.storage.getBool(keySbsScroll,true); + + /* Now set up the events to enable syncronized scrolling... */ + const scrollLeft = function(event){ + const table = this.parentElement/*TD*/.parentElement/*TR*/. + parentElement/*TBODY*/.parentElement/*TABLE*/; + if( useSync() ){ + table.$txtPres.forEach((e)=>(e===this) ? 1 : (e.scrollLeft = this.scrollLeft)); + } + return false; + }; + const SCROLL_LEN = 64/* pixels to scroll for keyboard events */; + const keycodes = Object.assign(Object.create(null),{ + 37/*cursor left*/: -SCROLL_LEN, 39/*cursor right*/: SCROLL_LEN + }); + /** keydown handler for a diff element */ + const handleDiffKeydown = function(e){ + const len = keycodes[e.keyCode]; + if( !len ) return false; + if( useSync() ){ + this.$txtPres[0].scrollLeft += len; + }else if( this.$preCurrent ){ + this.$preCurrent.scrollLeft += len; + } + return false; + }; + /** + Sets up synchronized scrolling of table.splitdiff element + `diff`. If passed no argument, it scans the dom for elements to + initialize. The second argument is for this function's own + internal use. + + It's okay (but wasteful) to pass the same element to this + function multiple times: it will only be set up for sync + scrolling the first time it's passed to this function. + + Note that this setup is ignorant of the cbSync toggle: the toggle + is checked when scrolling, not when initializing the sync-scroll + capability. + */ + const initTableDiff = function f(diff, unifiedDiffs){ + if(!diff){ + let i, diffs; + diffs = document.querySelectorAll('table.splitdiff'); + for(i=0; i<diffs.length; ++i){ + f.call(this, diffs[i], false); + } + diffs = document.querySelectorAll('table.udiff'); + for(i=0; i<diffs.length; ++i){ + f.call(this, diffs[i], true); + } + return this; + } + diff.$txtCols = diff.querySelectorAll('td.difftxt'); + diff.$txtPres = diff.querySelectorAll('td.difftxt pre'); + if(!unifiedDiffs){ + diff.$txtPres.forEach(function(e){ + if(!e.classList.contains('scroller')){ + D.addClass(e, 'scroller'); + e.addEventListener('scroll', scrollLeft, false); + } + }); + diff.tabIndex = 0; + if(!diff.classList.contains('scroller')){ + /* Keyboard-based scrolling requires special-case handling to + ensure that we scroll the proper side of the diff when sync + is off. */ + D.addClass(diff, 'scroller'); + diff.addEventListener('keydown', handleDiffKeydown, false); + const handleLRClick = function(ev){ + diff.$preCurrent = this /* NOT ev.target, which is probably a child element */; + }; + diff.$txtPres.forEach((e)=>e.addEventListener('click', handleLRClick, false)); + } + } + return this; + } + window.fossil.page.tweakSbsDiffs = function(){ + document.querySelectorAll('table.splitdiff').forEach((e)=>initTableDiff(e)); + }; + initTableDiff(); +}, false); |
