Index: skins/ardoise/css.txt ================================================================== --- skins/ardoise/css.txt +++ skins/ardoise/css.txt @@ -572,10 +572,13 @@ white-space: nowrap; background: #000; border: 2px solid #bbb; border-radius: 5px } +table.numbered-lines td.file-content > pre { + margin-top: -2px/*offset CODE tag border*/; +} pre > code { padding: 1rem 1.5rem; white-space: pre } td, Index: skins/eagle/css.txt ================================================================== --- skins/eagle/css.txt +++ skins/eagle/css.txt @@ -399,11 +399,11 @@ div.filetreeline:hover { background-color: #7EA2D9; } -div.selectedText { +table.numbered-lines td.line-numbers span.selected-line { background-color: #7EA2D9; } .statistics-report-graph-line { background-color: #7EA2D9; Index: skins/xekri/css.txt ================================================================== --- skins/xekri/css.txt +++ skins/xekri/css.txt @@ -1000,15 +1000,15 @@ /************************************** * Did not encounter these */ /* selected lines of text within a linenumbered artifact display */ -div.selectedText { +table.numbered-lines td.line-numbers span.selected-line { font-weight: bold; color: #00f; background-color: #d5d5ff; - border: 1px #00f solid; + border-color: #00f; } /* format for missing privileges note on user setup page */ p.missingPriv { color: #00f; Index: src/ajax.c ================================================================== --- src/ajax.c +++ src/ajax.c @@ -130,11 +130,12 @@ wiki_render_by_mimetype(pContent, zMime); break; default:{ const char *zContent = blob_str(pContent); if(AJAX_PREVIEW_LINE_NUMBERS & flags){ - output_text_with_line_numbers(zContent, "on"); + output_text_with_line_numbers(zContent, blob_size(pContent), + zName, "on"); }else{ const char *zExt = strrchr(zName,'.'); if(zExt && zExt[1]){ CX("
%h
", zExt+1, zContent); Index: src/attach.c ================================================================== --- src/attach.c +++ src/attach.c @@ -617,11 +617,11 @@ const char *z; content_get(ridSrc, &attach); blob_to_utf8_no_bom(&attach, 0); z = blob_str(&attach); if( zLn ){ - output_text_with_line_numbers(z, zLn); + output_text_with_line_numbers(z, blob_size(&attach), zName, zLn); }else{ @
       @ %h(z)
       @ 
} Index: src/default.css ================================================================== --- src/default.css +++ src/default.css @@ -440,16 +440,10 @@ content:"'"; } span.usertype:after { content:"'"; } -div.selectedText { - font-weight: bold; - color: blue; - background-color: #d5d5ff; - border: 1px blue solid; -} p.missingPriv { color: blue; } span.wikiruleHead { font-weight: bold; @@ -953,14 +947,19 @@ color: darkred; background: yellow; opacity: 0.7; } .hidden { - position: absolute; - opacity: 0; - pointer-events: none; - display: none; + /* The framework-wide way of hiding elements is to assign them this + CSS class. To make them visible again, remove it. The !important + qualifiers are unfortunate but sometimes necessary when hidden + element has other classes which specify visibility-related + options. */ + position: absolute !important; + opacity: 0 !important; + pointer-events: none !important; + display: none !important; } input { max-width: 95%; } textarea { @@ -1158,5 +1157,131 @@ .input-with-label > label { font-weight: initial; margin: 0 0.25em 0 0.25em; vertical-align: middle; } + +table.numbered-lines { + width: 100%; + table-layout: fixed /* required to keep ultra-wide code from exceeding + window width, and instead force a scrollbar + on them. */; +} +table.numbered-lines > tbody > tr { + font-family: monospace; + font-size: 1.2em; + line-height: 1.35; + white-space: pre; +} +table.numbered-lines > tbody > tr > td { + font-family: inherit; + font-size: inherit; + line-height: inherit; + white-space: inherit; + margin: 0; + vertical-align: top; + padding: 0.25em 0 0 0 /*prevents slight overlap at top */; +} +table.numbered-lines td.line-numbers { + width: 4.5em; +} +table.numbered-lines td.line-numbers > span:first-of-type { + margin-top: 0.25em/*must match top PADDING of + td.file-content > pre > code*/; +} +table.numbered-lines td.line-numbers > span { + display: block; + margin: 0; + padding: 0; + line-height: inherit; + font-size: inherit; + font-family: inherit; + cursor: pointer; + white-space: pre; + margin-right: 2px/*keep selection from nudging the right column */; + text-align: right; +} +table.numbered-lines td.line-numbers > span:hover { + background-color: rgba(112, 112, 112, 0.25); +} +table.numbered-lines td.file-content { + padding-left: 0.25em; +} +table.numbered-lines td.file-content > pre, +table.numbered-lines td.file-content > pre > code { + margin: 0; + padding: 0; + line-height: inherit; + font-size: inherit; + font-family: inherit; + white-space: pre; + display: block/*necessary for certain skins!*/; +} +table.numbered-lines td.file-content > pre { +} +table.numbered-lines td.file-content > pre > code { + overflow: auto; + padding-left: 0.5em; + padding-right: 0.5em; + padding-top: 0.25em/*any top padding here must match the top MARGIN of + td.line-numbers's first span child or the + lines/code will get misaligned. */; + padding-bottom: 0.25em/*prevents a slight overlap at bottom from + triggering a scroller*/; +} +table.numbered-lines td.file-content > pre > code > * { + /* Defense against syntax highlighters indirectly messing up these + properties... */ + line-height: inherit; + font-size: inherit; + font-family: inherit; +} +table.numbered-lines td.line-numbers span.selected-line/*replacement*/ { + font-weight: bold; + color: blue; + background-color: #d5d5ff; + border: 1px blue solid; + border-top-width: 0; + border-bottom-width: 0; + padding: 0; + margin: 0; +} +table.numbered-lines td.line-numbers span.selected-line.start { + border-top-width: 1px; + margin-top: -1px/*restore alignment*/; +} +table.numbered-lines td.line-numbers span.selected-line.end { + border-bottom-width: 1px; + margin-top: -1px/*restore alignment*/; +} +table.numbered-lines td.line-numbers span.selected-line.start.end { + margin-top: -2px/*restore alignment*/; +} + +.fossil-tooltip { + text-align: center; + padding: 0.2em 1em; + border: 1px solid black; + border-radius: 0.25em; + position: absolute; + display: inline-block; + z-index: 100; + box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.75); + background-color: inherit; + font-size: 80%; +} + +.fossil-toast {/* "toast"-style popup message */ + padding: 0.25em 0.5em; + margin: 0; + border-radius: 0.25em; + font-size: 1em; + opacity: 0.8; + border-size: 1px; + border-style: dotted; + border-color: rgb( 127, 127, 127, 0.5 ); +} + +blockquote.file-content { + /* file content block in the /file page */ + margin: 0 1em; +} Index: src/diff.c ================================================================== --- src/diff.c +++ src/diff.c @@ -125,11 +125,11 @@ ** in the count even if it lacks the \n terminator. If an empty string ** is specified, the number of lines is zero. For the purposes of this ** function, a string is considered empty if it contains no characters ** -OR- it contains only NUL characters. */ -static int count_lines( +int count_lines( const char *z, int n, int *pnLine ){ int nLine; Index: src/file.c ================================================================== --- src/file.c +++ src/file.c @@ -2397,5 +2397,14 @@ changeCount); }else{ fossil_print("Touched %d file(s)\n", changeCount); } } + +/* +** If zFileName is not NULL and contains a '.', this returns a pointer +** to the position after the final '.', else it returns NULL. +*/ +const char * file_extension(const char *zFileName){ + const char * zExt = strrchr(zFileName, '.'); + return zExt ? &zExt[1] : 0; +} Index: src/fossil.bootstrap.js ================================================================== --- src/fossil.bootstrap.js +++ src/fossil.bootstrap.js @@ -1,6 +1,18 @@ "use strict"; +(function () { + /* CustomEvent polyfill, courtesy of Mozilla: + https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent + */ + if(typeof window.CustomEvent === "function") return false; + window.CustomEvent = function(event, params) { + if(!params) params = {bubbles: false, cancelable: false, detail: null}; + const evt = document.createEvent('CustomEvent'); + evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail ); + return evt; + }; +})(); (function(global){ /* Bootstrapping bits for the global.fossil object. Must be loaded after style.c:style_emit_script_tag() has initialized that object. */ ADDED src/fossil.copybutton.js Index: src/fossil.copybutton.js ================================================================== --- /dev/null +++ src/fossil.copybutton.js @@ -0,0 +1,116 @@ +(function(F/*fossil object*/){ + /** + A basic API for creating and managing a copy-to-clipboard button. + + Requires: fossil.bootstrap, fossil.dom + */ + const D = F.dom; + + /** + Initializes element e as a copy button using the given options + object. + + The first argument may be a DOM element or a string (CSS selector + suitable for use with document.querySelector()). + + Options: + + .copyFromElement: DOM element + + .copyFromId: DOM element ID + + One of copyFromElement or copyFromId must be provided, but copyFromId + may optionally be provided via e.dataset.copyFromId. + + .extractText: optional callback which is triggered when the copy + button is clicked. I tmust return the text to copy to the + clipboard. The default is to extract it from the copy-from + element, using its [value] member, if it has one, else its + [innerText]. A client-provided callback may use any data source + it likes, so long as it's synchronous. If this function returns a + falsy value then the clipboard is not modified. This function is + called with the fully expanded/resolved options object as its + "this" (that's a different instance than the one passed to this + function!). + + .cssClass: optional CSS class, or list of classes, to apply to e. + + .style: optional object of properties to copy directly into + e.style. + + .oncopy: an optional callback function which is added as an event + listener for the 'text-copied' event (see below). There is + functionally no difference from setting this option or adding a + 'text-copied' event listener to the element, and this option is + considered to be a convenience form of that. + + Note that this function's own defaultOptions object holds default + values for some options. Any changes made to that object affect + any future calls to this function. + + Be aware that clipboard functionality might or might not be + available in any given environment. If this button appears to + have no effect, that may be because it is not enabled/available + in the current platform. + + The copy button emits custom event 'text-copied' after it has + successfully copied text to the clipboard. The event's "detail" + member is an object with a "text" property holding the copied + text. Other properties may be added in the future. The event is + not fired if copying to the clipboard fails (e.g. is not + available in the current environment). + + Returns the copy-initialized element. + + Example: + + const button = fossil.copyButton('#my-copy-button', { + copyFromId: 'some-other-element-id' + }); + button.addEventListener('text-copied',function(ev){ + fossil.dom.flashOnce(ev.target); + console.debug("Copied text:",ev.detail.text); + }); + */ + F.copyButton = function f(e, opt){ + if('string'===typeof e){ + e = document.querySelector(e); + } + opt = F.mergeLastWins(f.defaultOptions, opt); + if(opt.cssClass){ + D.addClass(e, opt.cssClass); + } + var srcId, srcElem; + if(opt.copyFromElement){ + srcElem = opt.copyFromElement; + }else if((srcId = opt.copyFromId || e.dataset.copyFromId)){ + srcElem = document.querySelector('#'+srcId); + } + const extract = opt.extractText || ( + undefined===srcElem.value ? ()=>srcElem.innerText : ()=>srcElem.value + ); + D.copyStyle(e, opt.style); + e.addEventListener( + 'click', + function(){ + const txt = extract.call(opt); + if(txt && D.copyTextToClipboard(txt)){ + e.dispatchEvent(new CustomEvent('text-copied',{ + detail: {text: txt} + })); + } + }, + false + ); + if('function' === typeof opt.oncopy){ + e.addEventListener('text-copied', opt.oncopy, false); + } + return e; + }; + + F.copyButton.defaultOptions = { + cssClass: 'copy-button', + style: {/*properties copied as-is into element.style*/} + }; + +})(window.fossil); Index: src/fossil.dom.js ================================================================== --- src/fossil.dom.js +++ src/fossil.dom.js @@ -465,10 +465,103 @@ e = src.querySelector(x); if(!e){ e = new Error("Cannot find DOM element: "+x); console.error(e, src); throw e; + } + return e; + }; + + /** + "Blinks" the given element a single time for the given number of + milliseconds, defaulting (if the 2nd argument is falsy or not a + number) to flashOnce.defaultTimeMs. If a 3rd argument is passed + in, it must be a function, and it gets callback back at the end + of the asynchronous flashing processes. + + This will only activate once per element during that timeframe - + further calls will become no-ops until the blink is + completed. This routine adds a dataset member to the element for + the duration of the blink, to allow it to block multiple blinks. + + If passed 2 arguments and the 2nd is a function, it behaves as if + it were called as (arg1, undefined, arg2). + + Returns e, noting that the flash itself is asynchronous and may + still be running, or not yet started, when this function returns. + */ + dom.flashOnce = function f(e,howLongMs,afterFlashCallback){ + if(e.dataset.isBlinking){ + return; + } + if(2===arguments.length && 'function' ===typeof howLongMs){ + afterFlashCallback = howLongMs; + howLongMs = f.defaultTimeMs; + } + if(!howLongMs || 'number'!==typeof howLongMs){ + howLongMs = f.defaultTimeMs; + } + e.dataset.isBlinking = true; + const transition = e.style.transition; + e.style.transition = "opacity "+howLongMs+"ms ease-in-out"; + const opacity = e.style.opacity; + e.style.opacity = 0; + setTimeout(function(){ + e.style.transition = transition; + e.style.opacity = opacity; + delete e.dataset.isBlinking; + if(afterFlashCallback) afterFlashCallback(); + }, howLongMs); + return e; + }; + dom.flashOnce.defaultTimeMs = 400; + + /** + Attempts to copy the given text to the system clipboard. Returns + true if it succeeds, else false. + */ + dom.copyTextToClipboard = function(text){ + if( window.clipboardData && window.clipboardData.setData ){ + clipboardData.setData('Text',text); + return true; + }else{ + const x = document.createElement("textarea"); + x.style.position = 'fixed'; + x.value = text; + document.body.appendChild(x); + x.select(); + var rc; + try{ + document.execCommand('copy'); + rc = true; + }catch(err){ + rc = false; + }finally{ + document.body.removeChild(x); + } + return rc; + } + }; + + /** + Copies all properties from the 2nd argument (a plain object) into + the style member of the first argument (DOM element or a + forEach-capable list of elements). If the 2nd argument is falsy + or empty, this is a no-op. + + Returns its first argument. + */ + dom.copyStyle = function f(e, style){ + if(e.forEach){ + e.forEach((x)=>f(x, style)); + return e; + } + if(style){ + let k; + for(k in style){ + if(style.hasOwnProperty(k)) e.style[k] = style[k]; + } } return e; }; return F.dom = dom; ADDED src/fossil.numbered-lines.js Index: src/fossil.numbered-lines.js ================================================================== --- /dev/null +++ src/fossil.numbered-lines.js @@ -0,0 +1,123 @@ +(function callee(arg){ + /* + JS counterpart of info.c:output_text_with_line_numbers() + which ties an event handler to the line numbers to allow + selection of individual lines or ranges. + + Requires: fossil.bootstrap, fossil.dom, fossil.popupwidget, + fossil.copybutton + */ + var tbl = arg || document.querySelectorAll('table.numbered-lines'); + if(!tbl) return /* no matching elements */; + else if(!arg){ + if(tbl.length>1){ /* multiple query results: recurse */ + tbl.forEach( (t)=>callee(t) ); + return; + }else{/* single query result */ + tbl = tbl[0]; + } + } + const F = window.fossil, D = F.dom; + const tdLn = tbl.querySelector('td.line-numbers'); + const lineState = { + urlArgs: (window.location.search||'?').replace(/&?\bln=[^&]*/,''), + start: 0, end: 0 + }; + + const lineTip = new fossil.PopupWidget({ + style: { + cursor: 'pointer' + }, + refresh: function(){ + const link = this.state.link; + D.clearElement(link); + if(lineState.start){ + const ls = [lineState.start]; + if(lineState.end) ls.push(lineState.end); + link.dataset.url = ( + window.location.toString().split('?')[0] + + lineState.urlArgs + '&ln='+ls.join('-') + ); + D.append( + D.clearElement(link), + ' ', + (ls.length===1 ? 'line ' : 'lines ')+ls.join('-') + ); + }else{ + D.append(link, "No lines selected."); + } + }, + init: function(){ + const e = this.e; + const btnCopy = D.span(), + link = D.span(); + this.state = {link}; + F.copyButton(btnCopy,{ + copyFromElement: link, + extractText: ()=>link.dataset.url, + oncopy: (ev)=>{ + D.flashOnce(ev.target, undefined, ()=>lineTip.hide()); + F.toast("Copied link to clipboard."); + } + }); + this.e.addEventListener('click', ()=>btnCopy.click(), false); + D.append(this.e, btnCopy, link) + } + }); + + tbl.addEventListener('click', ()=>lineTip.hide(), true); + + tdLn.addEventListener('click', function f(ev){ + if('SPAN'!==ev.target.tagName) return; + else if('number' !== typeof f.mode){ + f.mode = 0 /*0=none selected, 1=1 selected, 2=2 selected*/; + f.spans = tdLn.querySelectorAll('span'); + f.selected = tdLn.querySelectorAll('span.selected-line'); + f.unselect = (e)=>D.removeClass(e, 'selected-line','start','end'); + } + ev.stopPropagation(); + const ln = +ev.target.innerText; + if(2===f.mode){/*Reset selection*/ + f.mode = 0; + } + if(0===f.mode){/*Select single line*/ + lineState.end = 0; + lineState.start = ln; + f.mode = 1; + }else if(1===f.mode){ + if(ln === lineState.start){/*Unselect line*/ + lineState.start = 0; + f.mode = 0; + }else{/*Select range*/ + if(ln=lineState.start){ + let i = lineState.start, end = lineState.end || lineState.start, span = f.spans[i-1]; + for( ; i<=end && span; span = f.spans[i++] ){ + span.classList.add('selected-line'); + f.selected.push(span); + if(i===lineState.start) span.classList.add('start'); + if(i===end) span.classList.add('end'); + } + } + lineTip.refresh().show(rect.right+3, rect.top-4); + } + }, false); + +})(); ADDED src/fossil.popupwidget.js Index: src/fossil.popupwidget.js ================================================================== --- /dev/null +++ src/fossil.popupwidget.js @@ -0,0 +1,229 @@ +(function(F/*fossil object*/){ + /** + A very basic tooltip-like widget. It's intended to be popped up + to display basic information or basic user interaction + components, e.g. a copy-to-clipboard button. + + Requires: fossil.bootstrap, fossil.dom + */ + const D = F.dom; + + /** + Creates a new tooltip-like widget using the given options object. + + Options: + + .refresh: callback which is called just before the tooltip is + revealed or moved. It must refresh the contents of the tooltip, + if needed, by applying the content to/within this.e, which is the + base DOM element for the tooltip (and is a child of + document.body). If the contents are static and set up via the + .init option then this callback is not needed. + + .adjustX: an optional callback which is called when the tooltip + is to be displayed at a given position and passed the X + viewport-relative coordinate. This routine must either return its + argument as-is or return an adjusted value. The intent is to + allow a given tooltip may be positioned more appropriately for a + given context, if needed (noting that the desired position can, + and probably should, be passed to the show() method + instead). This class's API assumes that clients give it + viewport-relative coordinates, and it will take care to translate + those to page-relative, so this callback should not do so. + + .adjustY: the Y counterpart of adjustX. + + .init: optional callback called one time to initialize the state + of the tooltip. This is called after the this.e has been created + and added (initially hidden) to the DOM. If this is called, it is + removed from the object immediately after it is called. + + All callback options are called with the PopupWidget object as + their "this". + + + .cssClass: optional CSS class, or list of classes, to apply to + the new element. + + .style: optional object of properties to copy directly into + the element's style object. + + The options passed to this constructor get normalized into a + separate object which includes any default values for options not + provided by the caller. That object is available this the + resulting PopupWidget's options property. Default values for any + options not provided by the caller are pulled from + PopupWidget.defaultOptions, and modifying those affects all + future calls to this method but has no effect on existing + instances. + + + Example: + + const tip = new fossil.PopupWidget({ + init: function(){ + // optionally populate DOM element this.e with the widget's + // content. + }, + refresh: function(){ + // (re)populate/refresh the contents of the main + // wrapper element, this.e. + } + }); + + tip.show(50, 100); + // ^^^ viewport-relative coordinates. See show() for other options. + + */ + F.PopupWidget = function f(opt){ + opt = F.mergeLastWins(f.defaultOptions,opt); + this.options = opt; + const e = this.e = D.addClass(D.div(), opt.cssClass); + this.show(false); + if(opt.style){ + let k; + for(k in opt.style){ + if(opt.style.hasOwnProperty(k)) e.style[k] = opt.style[k]; + } + } + D.append(document.body, e/*must be in the DOM for size calc. to work*/); + D.copyStyle(e, opt.style); + if(opt.init){ + opt.init.call(this); + delete opt.init; + } + }; + + /** + Default options for the PopupWidget constructor. These values are + used for any options not provided by the caller. Any changes made + to this instace affect future calls to PopupWidget() but have no + effect on existing instances. + */ + F.PopupWidget.defaultOptions = { + cssClass: 'fossil-tooltip', + style: undefined /*{optional properties copied as-is into element.style}*/, + adjustX: (x)=>x, + adjustY: (y)=>y, + refresh: function(){}, + init: undefined /* optional initialization function */ + }; + + F.PopupWidget.prototype = { + + /** Returns true if the widget is currently being shown, else false. */ + isShown: function(){return !this.e.classList.contains('hidden')}, + + /** Calls the refresh() method of the options object and returns + this object. */ + refresh: function(){ + if(this.options.refresh){ + this.options.refresh.call(this); + } + return this; + }, + + /** + Shows or hides the tooltip. + + Usages: + + (bool showIt) => hide it or reveal it at its last position. + + (x, y) => reveal/move it at/to the given + relative-to-the-viewport position, which will be adjusted to make + it page-relative. + + (DOM element) => reveal/move it at/to a position based on the + the given element (adjusted slightly). + + For the latter two, this.options.adjustX() and adjustY() will + be called to adjust it further. + + Returns this object. + + Sidebar: showing/hiding the widget is, as is conventional for + this framework, done by removing/adding the 'hidden' CSS class + to it, so that class must be defined appropriately. + */ + show: function(){ + var x = undefined, y = undefined, showIt; + if(2===arguments.length){ + x = arguments[0]; + y = arguments[1]; + showIt = true; + }else if(1===arguments.length){ + if(arguments[0] instanceof HTMLElement){ + const p = arguments[0]; + const r = p.getBoundingClientRect(); + x = r.x + r.x/5; + y = r.y - r.height/2; + showIt = true; + }else{ + showIt = !!arguments[0]; + } + } + if(showIt){ + this.refresh(); + x = this.options.adjustX.call(this,x); + y = this.options.adjustY.call(this,y); + x += window.pageXOffset; + y += window.pageYOffset; + } + if(showIt){ + if('number'===typeof x && 'number'===typeof y){ + this.e.style.left = x+"px"; + this.e.style.top = y+"px"; + } + D.removeClass(this.e, 'hidden'); + }else{ + D.addClass(this.e, 'hidden'); + delete this.e.style.removeProperty('left'); + delete this.e.style.removeProperty('top'); + } + return this; + }, + + hide: function(){return this.show(false)} + }/*F.PopupWidget.prototype*/; + + /** + Convenience wrapper around a PopupWidget which pops up a shared + PopupWidget instance to show toast-style messages (commonly seen + on Android). Its arguments may be anything suitable for passing + to fossil.dom.append(), and each argument is first append()ed to + the toast widget, then the widget is shown for + F.toast.config.displayTimeMs milliseconds. This is called while + a toast is currently being displayed, the first will be overwritten + and the time until the message is hidden will be reset. + + The toast is always shown at the viewport-relative coordinates + defined by the F.toast.config.position. + + The toaster's DOM element has the CSS classes fossil-tooltip + and fossil-toast, so can be style via those. + */ + F.toast = function f(/*...*/){ + if(!f.toast){ + f.toast = function ff(argsObject){ + if(!ff.toaster) ff.toaster = new F.PopupWidget({ + cssClass: ['fossil-tooltip', 'fossil-toast'] + }); + if(f._timer) clearTimeout(f._timer); + D.clearElement(ff.toaster.e); + var i = 0; + for( ; i < argsObject.length; ++i ){ + D.append(ff.toaster.e, argsObject[i]); + }; + ff.toaster.show(f.config.position.x, f.config.position.y); + f._timer = setTimeout(()=>ff.toaster.hide(), f.config.displayTimeMs); + }; + } + f.toast(arguments); + }; + F.toast.config = { + position: { x: 5, y: 5 /*viewport-relative, pixels*/ }, + displayTimeMs: 2500 + }; + +})(window.fossil); Index: src/info.c ================================================================== --- src/info.c +++ src/info.c @@ -2011,27 +2011,37 @@ manifest_destroy(pManifest); return rid; } /* -** The "z" argument is a string that contains the text of a source code -** file. This routine appends that text to the HTTP reply with line numbering. +** The "z" argument is a string that contains the text of a source +** code file and nZ is its length in bytes. This routine appends that +** text to the HTTP reply with line numbering. +** +** zName is the content's file name, if any (it may be NULL). If that +** name contains a '.' then the part after the final '.' is used as +** the X part of a "language-X" CSS class on the generated CODE block. ** ** zLn is the ?ln= parameter for the HTTP query. If there is an argument, ** then highlight that line number and scroll to it once the page loads. ** If there are two line numbers, highlight the range of lines. ** Multiple ranges can be highlighed by adding additional line numbers ** separated by a non-digit character (also not one of [-,.]). */ void output_text_with_line_numbers( const char *z, + int nZ, + const char *zName, const char *zLn ){ int iStart, iEnd; /* Start and end of region to highlight */ int n = 0; /* Current line number */ int i = 0; /* Loop index */ int iTop = 0; /* Scroll so that this line is on top of screen. */ + int nLine = 0; /* content line count */ + int nSpans = 0; /* number of distinct zLn spans */ + const char *zExt = file_extension(zName); Stmt q; iStart = iEnd = atoi(zLn); db_multi_exec( "CREATE TEMP TABLE lnos(iStart INTEGER PRIMARY KEY, iEnd INTEGER)"); @@ -2047,56 +2057,101 @@ while( fossil_isdigit(zLn[i]) ) i++; if( iEndiStart - 2 ) iTop = iStart-2; - } - db_finalize(&q); - @
-  while( z[0] ){
-    n++;
-    db_prepare(&q,
-      "SELECT min(iStart), max(iEnd) FROM lnos"
-      " WHERE iStart <= %d AND iEnd >= %d", n, n);
-    if( db_step(&q)==SQLITE_ROW ){
-      iStart = db_column_int(&q, 0);
-      iEnd = db_column_int(&q, 1);
-    }
-    db_finalize(&q);
-    for(i=0; z[i] && z[i]!='\n'; i++){}
-    if( n==iTop ) cgi_append_content("", -1);
-    if( n==iStart ){
-      cgi_append_content("
",-1); - } - cgi_printf("%6d ", n); - if( i>0 ){ - char *zHtml = htmlize(z, i); - cgi_append_content(zHtml, -1); - fossil_free(zHtml); - } - if( n==iTop ) cgi_append_content("", -1); - if( n==iEnd ) cgi_append_content("
", -1); - else cgi_append_content("\n", 1); - z += i; - if( z[0]=='\n' ) z++; - } - if( n"); - @
+ /*cgi_printf("", nSpans);*/ + cgi_append_content("" + "
", -1); + iStart = iEnd = 0; + count_lines(z, nZ, &nLine); + for( n=1 ; n<=nLine; ++n ){ + const char * zAttr = ""; + const char * zId = ""; + if(nSpans>0 && iEnd==0){/*Grab the next range of zLn marking*/ + db_prepare(&q, "SELECT iStart, iEnd FROM lnos " + "WHERE iStart >= %d ORDER BY iStart", n); + if( db_step(&q)==SQLITE_ROW ){ + iStart = db_column_int(&q, 0); + iEnd = db_column_int(&q, 1); + if(!iTop){ + iTop = iStart - 15 + (iEnd-iStart)/4; + if( iTop>iStart - 2 ) iTop = iStart-2; + } + }else{ + /* Note that overlapping multi-spans, e.g. 10-15+12-20, + can cause us to miss a row. */ + iStart = iEnd = 0; + } + db_finalize(&q); + --nSpans; + /*cgi_printf("", iStart, iEnd);*/ + } + if(n==iTop) { + zId = " id='scrollToMe'"; + } + if(n==iStart){/*Figure out which CSS class(es) this line needs...*/ + if(n==iEnd){ + zAttr = " class='selected-line start end'"; + iEnd = 0; + }else{ + zAttr = " class='selected-line start'"; + } + iStart = 0; + }else if(n==iEnd){ + zAttr = " class='selected-line end'"; + iEnd = 0; + }else if( n>iStart && n%6d", zId, zAttr, n); + } + cgi_append_content("
",-1);
+  if(zExt && *zExt){
+    cgi_printf("",zExt);
+  }else{
+    cgi_append_content("", -1);
+  }
+  cgi_printf("%z", htmlize(z, nZ));
+  CX("
\n"); if( db_int(0, "SELECT EXISTS(SELECT 1 FROM lnos)") ){ builtin_request_js("scroll.js"); } + style_emit_fossil_js_apis(0, "dom", "copybutton", "popupwidget", + "numbered-lines", 0); } +/* +** COMMAND: test-line-numbers +** +** Usage: %fossil test-line-numbers FILE ?LN-SPEC? +** +*/ +void cmd_test_line_numbers(void){ + Blob content = empty_blob; + const char * zLn = ""; + const char * zFilename = 0; + + if(g.argc < 3){ + usage("FILE"); + }else if(g.argc>3){ + zLn = g.argv[3]; + } + db_find_and_open_repository(0,0); + zFilename = g.argv[2]; + fossil_print("%s %s\n", zFilename, zLn); + + blob_read_from_file(&content, zFilename, ExtFILE); + output_text_with_line_numbers(blob_str(&content), blob_size(&content), + zFilename, zLn); + blob_reset(&content); + fossil_print("%b\n", cgi_output_blob()); +} /* ** WEBPAGE: artifact ** WEBPAGE: file ** WEBPAGE: whatis @@ -2387,25 +2442,26 @@ if( zLn==0 || atoi(zLn)==0 ){ style_submenu_checkbox("ln", "Line Numbers", 0, 0); } blob_to_utf8_no_bom(&content, 0); zMime = mimetype_from_content(&content); - @
+ @
if( zMime==0 ){ const char *z, *zFileName, *zExt; z = blob_str(&content); zFileName = db_text(0, "SELECT name FROM mlink, filename" " WHERE filename.fnid=mlink.fnid" " AND mlink.fid=%d", rid); - zExt = zFileName ? strrchr(zFileName, '.') : 0; + zExt = file_extension(zFileName); if( zLn ){ - output_text_with_line_numbers(z, zLn); + output_text_with_line_numbers(z, blob_size(&content), + zFileName, zLn); }else if( zExt && zExt[1] ){ @
-          @ %h(z)
+          @ %h(z)
           @ 
}else{ @
           @ %h(z)
           @ 
Index: src/main.mk ================================================================== --- src/main.mk +++ src/main.mk @@ -223,15 +223,18 @@ $(SRCDIR)/default.css \ $(SRCDIR)/diff.tcl \ $(SRCDIR)/forum.js \ $(SRCDIR)/fossil.bootstrap.js \ $(SRCDIR)/fossil.confirmer.js \ + $(SRCDIR)/fossil.copybutton.js \ $(SRCDIR)/fossil.dom.js \ $(SRCDIR)/fossil.fetch.js \ + $(SRCDIR)/fossil.numbered-lines.js \ $(SRCDIR)/fossil.page.fileedit.js \ $(SRCDIR)/fossil.page.forumpost.js \ $(SRCDIR)/fossil.page.wikiedit.js \ + $(SRCDIR)/fossil.popupwidget.js \ $(SRCDIR)/fossil.storage.js \ $(SRCDIR)/fossil.tabs.js \ $(SRCDIR)/graph.js \ $(SRCDIR)/href.js \ $(SRCDIR)/login.js \ Index: src/scroll.js ================================================================== --- src/scroll.js +++ src/scroll.js @@ -1,2 +1,2 @@ /* Cause the page to scroll so that the #scrollToMe is visible */ -document.getElementById('scrollToMe').scrollIntoView(true); +(document.getElementById('scrollToMe')||document.body).scrollIntoView(true); Index: win/Makefile.mingw ================================================================== --- win/Makefile.mingw +++ win/Makefile.mingw @@ -635,15 +635,18 @@ $(SRCDIR)/default.css \ $(SRCDIR)/diff.tcl \ $(SRCDIR)/forum.js \ $(SRCDIR)/fossil.bootstrap.js \ $(SRCDIR)/fossil.confirmer.js \ + $(SRCDIR)/fossil.copybutton.js \ $(SRCDIR)/fossil.dom.js \ $(SRCDIR)/fossil.fetch.js \ + $(SRCDIR)/fossil.numbered-lines.js \ $(SRCDIR)/fossil.page.fileedit.js \ $(SRCDIR)/fossil.page.forumpost.js \ $(SRCDIR)/fossil.page.wikiedit.js \ + $(SRCDIR)/fossil.popupwidget.js \ $(SRCDIR)/fossil.storage.js \ $(SRCDIR)/fossil.tabs.js \ $(SRCDIR)/graph.js \ $(SRCDIR)/href.js \ $(SRCDIR)/login.js \ Index: win/Makefile.msc ================================================================== --- win/Makefile.msc +++ win/Makefile.msc @@ -556,15 +556,18 @@ "$(SRCDIR)\default.css" \ "$(SRCDIR)\diff.tcl" \ "$(SRCDIR)\forum.js" \ "$(SRCDIR)\fossil.bootstrap.js" \ "$(SRCDIR)\fossil.confirmer.js" \ + "$(SRCDIR)\fossil.copybutton.js" \ "$(SRCDIR)\fossil.dom.js" \ "$(SRCDIR)\fossil.fetch.js" \ + "$(SRCDIR)\fossil.numbered-lines.js" \ "$(SRCDIR)\fossil.page.fileedit.js" \ "$(SRCDIR)\fossil.page.forumpost.js" \ "$(SRCDIR)\fossil.page.wikiedit.js" \ + "$(SRCDIR)\fossil.popupwidget.js" \ "$(SRCDIR)\fossil.storage.js" \ "$(SRCDIR)\fossil.tabs.js" \ "$(SRCDIR)\graph.js" \ "$(SRCDIR)\href.js" \ "$(SRCDIR)\login.js" \ @@ -1150,15 +1153,18 @@ echo "$(SRCDIR)\default.css" >> $@ echo "$(SRCDIR)\diff.tcl" >> $@ echo "$(SRCDIR)\forum.js" >> $@ echo "$(SRCDIR)\fossil.bootstrap.js" >> $@ echo "$(SRCDIR)\fossil.confirmer.js" >> $@ + echo "$(SRCDIR)\fossil.copybutton.js" >> $@ echo "$(SRCDIR)\fossil.dom.js" >> $@ echo "$(SRCDIR)\fossil.fetch.js" >> $@ + echo "$(SRCDIR)\fossil.numbered-lines.js" >> $@ echo "$(SRCDIR)\fossil.page.fileedit.js" >> $@ echo "$(SRCDIR)\fossil.page.forumpost.js" >> $@ echo "$(SRCDIR)\fossil.page.wikiedit.js" >> $@ + echo "$(SRCDIR)\fossil.popupwidget.js" >> $@ echo "$(SRCDIR)\fossil.storage.js" >> $@ echo "$(SRCDIR)\fossil.tabs.js" >> $@ echo "$(SRCDIR)\graph.js" >> $@ echo "$(SRCDIR)\href.js" >> $@ echo "$(SRCDIR)\login.js" >> $@