diff options
| author | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
| commit | 4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch) | |
| tree | c296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/fossil.page.wikiedit.js | |
| download | relic-4f49b16079b27d7ce633619e4cddfdf2bffbc952.tar.gz | |
Diffstat (limited to 'src/fossil.page.wikiedit.js')
| -rw-r--r-- | src/fossil.page.wikiedit.js | 1659 |
1 files changed, 1659 insertions, 0 deletions
diff --git a/src/fossil.page.wikiedit.js b/src/fossil.page.wikiedit.js new file mode 100644 index 0000000..1b4bf76 --- /dev/null +++ b/src/fossil.page.wikiedit.js @@ -0,0 +1,1659 @@ +(function(F/*the fossil object*/){ + "use strict"; + /** + Client-side implementation of the /wikiedit app. Requires that + the fossil JS bootstrapping is complete and that several fossil + JS APIs have been installed: fossil.fetch, fossil.dom, + fossil.tabs, fossil.storage, fossil.confirmer, fossil.popupwidget. + + Custom events which can be listened for via + fossil.page.addEventListener(): + + - Event 'wiki-page-loaded': passes on information when it + loads a wiki (whether from the network or its internal local-edit + cache), in the form of an "winfo" object: + + { + name: string, + mimetype: mimetype string, + type: "normal" | "tag" | "checkin" | "branch" | "ticket" | "sandbox", + version: UUID string or null for a sandbox page or new page, + parent: parent UUID string or null if no parent, + isEmpty: true if page has no content (is "deleted"). + content: string, optional in most contexts + } + + The internal docs and code frequently use the term "winfo", and such + references refer to an object with that form. + + The fossil.page.wikiContent() method gets or sets the current + file content for the page. + + - Event 'wiki-saved': is fired when a commit completes, + passing on the same info as wiki-page-loaded. + + - Event 'wiki-content-replaced': when the editor's content is + replaced, as opposed to it being edited via user + interaction. This normally happens via selecting a file to + load. The event detail is the fossil.page object, not the current + file content. + + - Event 'wiki-preview-updated': when the preview is refreshed + from the server, this event passes on information about the preview + change in the form of an object: + + { + element: the DOM element which contains the content preview. + mimetype: the page's mimetype. + } + + Here's an example which can be used with the highlightjs code + highlighter to update the highlighting when the preview is + refreshed in "wiki" mode (which includes fossil-native wiki and + markdown): + + fossil.page.addEventListener( + 'wiki-preview-updated', + (ev)=>{ + if(ev.detail.mimetype!=='text/plain'){ + ev.detail.element.querySelectorAll( + 'code[class^=language-]' + ).forEach((e)=>hljs.highlightBlock(e)); + } + } + ); + */ + const E = (s)=>document.querySelector(s), + D = F.dom, + P = F.page; + P.config = { + /* Max number of locally-edited pages to stash, after which we + drop the least-recently used. */ + defaultMaxStashSize: 10, + useConfirmerButtons:{ + /* If true during fossil.page setup, certain buttons will use a + "confirmer" step, else they will not. The confirmer topic has + been the source of much contention in the forum. */ + save: false, + reload: true, + discardStash: true + }, + /** + If true, a keyboard combo of shift-enter (from the editor) + toggles between preview and edit modes. This is normally + desired but at least one software keyboard is known to + misinteract with this, treating an Enter after + automatically-capitalized letters as a shift-enter: + + https://fossil-scm.org/forum/forumpost/dbd5b68366147ce8 + + Maintenance note: /fileedit also uses this same key for the + same purpose. + */ + shiftEnterPreview: F.storage.getBool('edit-shift-enter-preview', true) + }; + + /** + $stash is an internal-use-only object for managing "stashed" + local edits, to help avoid that users accidentally lose content + by switching tabs or following links or some such. The basic + theory of operation is... + + All "stashed" state is stored using fossil.storage. + + - When the current wiki content is modified by the user, the + current state of the page is stashed. + + - When saving, the stashed entry for the previous version is + removed from the stash. + + - When "loading", we use any stashed state for the given + checkin/file combination. When forcing a re-load of content, + any stashed entry for that combination is removed from the + stash. + + - Every time P.stashContentChange() updates the stash, it is + pruned to $stash.prune.defaultMaxCount most-recently-updated + entries. + + - This API often refers to "winfo objects." Those are objects + with a minimum of {page,mimetype} properties (which must be + valid), and the page name is used as basis for the stash keys + for any given page. + + The structure of the stash is a bit convoluted for efficiency's + sake: we store a map of file info (winfo) objects separately from + those files' contents because otherwise we would be required to + JSONize/de-JSONize the file content when stashing/restoring it, + and that would be horribly inefficient (meaning "battery-consuming" + on mobile devices). + */ + const $stash = { + keys: { + index: F.page.name+'.index' + }, + /** + index: { + "PAGE_NAME": {wiki page info w/o content} + ... + } + + In F.storage we... + + - Store this.index under the key this.keys.index. + + - Store each page's content under the key + (P.name+'/PAGE_NAME'). These are stored separately from the + index entries to avoid having to JSONize/de-JSONize the + content. The assumption/hope is that the browser can store + those records "directly," without any intermediary + encoding/decoding going on. + */ + indexKey: function(winfo){return winfo.name}, + /** Returns the key for storing content for the given key suffix, + by prepending P.name to suffix. */ + contentKey: function(suffix){return P.name+'/'+suffix}, + /** Returns the index object, fetching it from the stash or creating + it anew on the first call. */ + getIndex: function(){ + if(!this.index){ + this.index = F.storage.getJSON( + this.keys.index, {} + ); + } + return this.index; + }, + _fireStashEvent: function(){ + if(this._disableNextEvent) delete this._disableNextEvent; + else F.page.dispatchEvent('wiki-stash-updated', this); + }, + /** + Returns the stashed version, if any, for the given winfo object. + */ + getWinfo: function(winfo){ + const ndx = this.getIndex(); + return ndx[this.indexKey(winfo)]; + }, + /** Serializes this object's index to F.storage. Returns this. */ + storeIndex: function(){ + if(this.index) F.storage.setJSON(this.keys.index,this.index); + return this; + }, + /** Updates the stash record for the given winfo + and (optionally) content. If passed 1 arg, only + the winfo stash is updated, else both the winfo + and its contents are (re-)stashed. Returns this. + */ + updateWinfo: function(winfo,content){ + const ndx = this.getIndex(), + key = this.indexKey(winfo), + old = ndx[key]; + const record = old || (ndx[key]={ + name: winfo.name + }); + record.mimetype = winfo.mimetype; + record.type = winfo.type; + record.parent = winfo.parent; + record.version = winfo.version; + record.stashTime = new Date().getTime(); + record.isEmpty = !!winfo.isEmpty; + record.attachments = winfo.attachments; + this.storeIndex(); + if(arguments.length>1){ + if(content) delete record.isEmpty; + F.storage.set(this.contentKey(key), content); + } + this._fireStashEvent(); + return this; + }, + /** + Returns the stashed content, if any, for the given winfo + object. + */ + stashedContent: function(winfo){ + return F.storage.get(this.contentKey(this.indexKey(winfo))); + }, + /** Returns true if we have stashed content for the given winfo + record or page name. */ + hasStashedContent: function(winfo){ + if('string'===typeof winfo) winfo = {name: winfo}; + return F.storage.contains(this.contentKey(this.indexKey(winfo))); + }, + /** Unstashes the given winfo record and its content. + Returns this. */ + unstash: function(winfo){ + const ndx = this.getIndex(), + key = this.indexKey(winfo); + delete winfo.stashTime; + delete ndx[key]; + F.storage.remove(this.contentKey(key)); + this.storeIndex(); + this._fireStashEvent(); + return this; + }, + /** + Clears all $stash entries from F.storage. Returns this. + */ + clear: function(){ + const ndx = this.getIndex(), + self = this; + let count = 0; + Object.keys(ndx).forEach(function(k){ + ++count; + const e = ndx[k]; + delete ndx[k]; + F.storage.remove(self.contentKey(k)); + }); + F.storage.remove(this.keys.index); + delete this.index; + if(count) this._fireStashEvent(); + return this; + }, + /** + Removes all but the maxCount most-recently-updated stash + entries, where maxCount defaults to this.prune.defaultMaxCount. + */ + prune: function f(maxCount){ + const ndx = this.getIndex(); + const li = []; + if(!maxCount || maxCount<0) maxCount = f.defaultMaxCount; + Object.keys(ndx).forEach((k)=>li.push(ndx[k])); + li.sort((l,r)=>l.stashTime - r.stashTime); + let n = 0; + while(li.length>maxCount){ + ++n; + const e = li.shift(); + this._disableNextEvent = true; + this.unstash(e); + console.warn("Pruned oldest local file edit entry:",e); + } + if(n) this._fireStashEvent(); + } + }; + $stash.prune.defaultMaxCount = P.config.defaultMaxStashSize || 10; + P.$stash = $stash /* we have to expose this for the new-page case :/ */; + + /** + Internal workaround to select the current preview mode + and fire a change event if the value actually changes + or if forceEvent is truthy. + */ + P.selectMimetype = function(modeValue, forceEvent){ + const s = this.e.selectMimetype; + if(!modeValue) modeValue = s.value; + else if(s.value != modeValue){ + s.value = modeValue; + forceEvent = true; + } + if(forceEvent){ + // Force UI update + s.dispatchEvent(new Event('change',{target:s})); + } + }; + + /** + Internal helper to get an edit status indicator for the given + winfo object. Pass it a winfo object or one of the "constants" + which are assigned as member properties of this function (see + below its definition). + */ + const getEditMarker = function f(winfo, textOnly){ + const esm = F.config.editStateMarkers; + if(f.NEW===winfo){ /* force is-new */ + return textOnly ? esm.isNew : + D.addClass(D.append(D.span(),esm.isNew), 'is-new'); + }else if(f.MODIFIED===winfo){ /* force is-modified */ + return textOnly ? esm.isModified : + D.addClass(D.append(D.span(),esm.isModified), 'is-modified'); + }else if(f.DELETED===winfo){/* force is-deleted */ + return textOnly ? esm.isDeleted : + D.addClass(D.append(D.span(),esm.isDeleted), 'is-deleted'); + }else if(winfo && winfo.version){ /* is existing page modified? */ + if($stash.getWinfo(winfo)){ + return textOnly ? esm.isModified : + D.addClass(D.append(D.span(),esm.isModified), 'is-modified'); + } + /*fall through*/ + } + else if(winfo){ /* is new non-sandbox or is modified sandbox? */ + if('sandbox'!==winfo.type){ + return textOnly ? esm.isNew : + D.addClass(D.append(D.span(),esm.isNew), 'is-new'); + }else if($stash.getWinfo(winfo)){ + return textOnly ? esm.isModified : + D.addClass(D.append(D.span(),esm.isModified), 'is-modified'); + } + } + return textOnly ? '' : D.span(); + }; + getEditMarker.NEW = 1; + getEditMarker.MODIFIED = 2; + getEditMarker.DELETED = 3; + + /** + Returns undefined if winfo is falsy, true if the given winfo + object appears to be "new", else returns false. + */ + const winfoIsNew = function(winfo){ + if(!winfo) return undefined; + else if('sandbox' === winfo.type) return false; + else return !winfo.version; + }; + + /** + Sets up and maintains the widgets for the list of wiki pages. + */ + const WikiList = { + e: { + filterCheckboxes: { + /*map of wiki page type to checkbox for list filtering purposes, + except for "sandbox" type, which is assumed to be covered by + the "normal" type filter. */}, + }, + cache: { + pageList: [], + optByName:{/*map of page names to OPTION object, to speed up + certain operations.*/}, + names: { + /* Map of page names to "something." We don't map to their + winfo bits because those regularly get swapped out via + de/serialization. We need this map to support the add-new-page + feature, to give us a way to check for dupes without asking + the server or walking through the whole selection list. + */} + }, + /** + Updates OPTION elements to reflect whether the page has local + changes or is new/unsaved. This implementation is horribly + inefficient, in that we have to walk and validate the whole + list for each stash-level change. + + If passed an argument, it is assumed to be an OPTION element + and only that element is updated, else all OPTION elements + in this.e.select are updated. + + Reminder to self: in order to mark is-edited/is-new state we + have to update the OPTION element's inner text to reflect the + is-modified/is-new flags, rather than use CSS classes to tag + them, because mobile Chrome can neither restyle OPTION elements + no render ::before content on them. We *also* use CSS tags, but + they aren't sufficient for the mobile browsers. + */ + _refreshStashMarks: function callee(option){ + if(!callee.eachOpt){ + const self = this; + callee.eachOpt = function(keyOrOpt){ + const opt = 'string'===typeof keyOrOpt ? self.e.select.options[keyOrOpt] : keyOrOpt; + const stashed = $stash.getWinfo({name:opt.value}); + var prefix = ''; + D.removeClass(opt, 'stashed', 'stashed-new', 'deleted'); + if(stashed){ + const isNew = winfoIsNew(stashed); + prefix = getEditMarker(isNew ? getEditMarker.NEW : getEditMarker.MODIFIED, true); + D.addClass(opt, isNew ? 'stashed-new' : 'stashed'); + D.removeClass(opt, 'deleted'); + }else if(opt.dataset.isDeleted){ + prefix = getEditMarker(getEditMarker.DELETED,true); + D.addClass(opt, 'deleted'); + } + opt.innerText = prefix + opt.value; + self.cache.names[opt.value] = true; + }; + } + if(arguments.length){ + callee.eachOpt(option); + }else{ + this.cache.names = {/*must reset it to acount for local page removals*/}; + Object.keys(this.e.select.options).forEach(callee.eachOpt); + } + }, + /** Removes the given wiki page entry from the page selection + list, if it's in the list. */ + removeEntry: function(name){ + const sel = this.e.select; + var ndx = sel.selectedIndex; + sel.value = name; + if(sel.selectedIndex>-1){ + if(ndx === sel.selectedIndex) ndx = -1; + sel.options.remove(sel.selectedIndex); + } + sel.selectedIndex = ndx; + delete this.cache.names[name]; + delete this.cache.optByName[name]; + this.cache.pageList = this.cache.pageList.filter((wi)=>name !== wi.name); + }, + + /** + Rebuilds the selection list. Necessary when it's loaded from + the server, we locally create a new page, or we remove a + locally-created new page. + */ + _rebuildList: function callee(){ + /* Jump through some hoops to integrate new/unsaved + pages into the list of existing pages... We use a map + as an intermediary in order to filter out any local-stash + dupes from server-side copies. */ + const list = this.cache.pageList; + if(!list) return; + if(!callee.sorticase){ + callee.sorticase = function(l,r){ + if(l===r) return 0; + l = l.toLowerCase(); + r = r.toLowerCase(); + return l<=r ? -1 : 1; + }; + } + const map = {}, ndx = $stash.getIndex(), sel = this.e.select; + D.clearElement(sel); + list.forEach((winfo)=>map[winfo.name] = winfo); + Object.keys(ndx).forEach(function(key){ + const winfo = ndx[key]; + if(!winfo.version/*new page*/) map[winfo.name] = winfo; + }); + const self = this; + Object.keys(map) + .sort(callee.sorticase) + .forEach(function(name){ + const winfo = map[name]; + const opt = D.option(sel, winfo.name); + const wtype = opt.dataset.wtype = + winfo.type==='sandbox' ? 'normal' : (winfo.type||'normal'); + const cb = self.e.filterCheckboxes[wtype]; + self.cache.optByName[winfo.name] = opt; + if(cb && !cb.checked) D.addClass(opt, 'hidden'); + if(winfo.isEmpty){ + opt.dataset.isDeleted = true; + } + self._refreshStashMarks(opt); + }); + D.enable(sel); + if(P.winfo) sel.value = P.winfo.name; + }, + + /** Loads the page list and populates the selection list. */ + loadList: function callee(){ + if(!callee.onload){ + const self = this; + callee.onload = function(list){ + self.cache.pageList = list; + self._rebuildList(); + F.message("Loaded page list."); + }; + } + if(P.initialPageList){ + /* ^^^ injected at page-creation time. */ + const list = P.initialPageList; + delete P.initialPageList; + callee.onload(list); + }else{ + F.fetch('wikiajax/list',{ + urlParams:{verbose:true}, + responseType: 'json', + onload: callee.onload + }); + } + return this; + }, + + /** + Returns true if the given name appears to be a valid + wiki page name, noting that the final arbitrator is the + server. On validation error it emits a message via fossil.error() + and returns false. + */ + validatePageName: function(name){ + var err; + if(!name){ + err = "may not be empty"; + }else if(this.cache.names.hasOwnProperty(name)){ + err = "page already exists: "+name; + }else if(name.length>100){ + err = "too long (limit is 100)"; + }else if(/\s{2,}/.test(name)){ + err = "multiple consecutive spaces"; + }else if(/[\t\r\n]/.test(name)){ + err = "contains control character(s)"; + }else{ + let i = 0, n = name.length, c; + for( ; i < n; ++i ){ + if(name.charCodeAt(i)<0x20){ + err = "contains control character(s)"; + break; + } + } + } + if(err){ + F.error("Invalid name:",err); + } + return !err; + }, + + /** + If the given name is valid, a new page with that (trimmed) name + is added to the local stash. + */ + addNewPage: function(name){ + name = name.trim(); + if(!this.validatePageName(name)) return false; + var wtype = 'normal'; + if(0===name.indexOf('checkin/')) wtype = 'checkin'; + else if(0===name.indexOf('branch/')) wtype = 'branch'; + else if(0===name.indexOf('ticket/')) wtype = 'ticket'; + else if(0===name.indexOf('tag/')) wtype = 'tag'; + /* ^^^ note that we're not validating that, e.g., checkin/XYZ + has a full artifact ID after "checkin/". */ + const winfo = { + name: name, type: wtype, mimetype: 'text/x-org', + version: null, parent: null + }; + this.cache.pageList.push( + winfo/*keeps entry from getting lost from the list on save*/ + ); + $stash.updateWinfo(winfo, ''); + this._rebuildList(); + P.loadPage(winfo.name); + return true; + }, + + /** + Installs a wiki page selection list into the given parent DOM + element and loads the page list from the server. + */ + init: function(parentElem){ + const sel = D.select(), btn = D.addClass(D.button("Reload page list"), 'save'); + this.e.select = sel; + D.addClass(parentElem, 'WikiList'); + D.clearElement(parentElem); + D.append( + parentElem, + D.append(D.fieldset("Select a page to edit"), + sel) + ); + D.attr(sel, 'size', 12); + D.option(D.disable(D.clearElement(sel)), undefined, "Loading..."); + + /** Set up filter checkboxes for the various types + of wiki pages... */ + const fsFilter = D.addClass(D.fieldset("Page types"),"page-types-list"), + fsFilterBody = D.div(), + filters = ['normal', 'branch/...', 'tag/...', 'checkin/...', 'ticket/...'] + ; + D.append(fsFilter, fsFilterBody); + D.addClass(fsFilterBody, 'flex-container', 'flex-column', 'stretch'); + + // Add filters by page type... + const self = this; + const filterByType = function(wtype, show){ + sel.querySelectorAll('option[data-wtype='+wtype+']').forEach(function(opt){ + if(show) opt.classList.remove('hidden'); + else opt.classList.add('hidden'); + }); + }; + filters.forEach(function(label){ + const wtype = label.split('/')[0]; + const cbId = 'wtype-filter-'+wtype, + lbl = D.attr(D.append(D.label(),label), + 'for', cbId), + cb = D.attr(D.input('checkbox'), 'id', cbId); + D.append(fsFilterBody, D.append(D.span(), cb, lbl)); + self.e.filterCheckboxes[wtype] = cb; + cb.checked = true; + filterByType(wtype, cb.checked); + cb.addEventListener( + 'change', + function(ev){filterByType(wtype, ev.target.checked)}, + false + ); + }); + { /* add filter for "deleted" pages */ + const cbId = 'wtype-filter-deleted', + lbl = D.attr(D.append(D.label(), + getEditMarker(getEditMarker.DELETED,false), + 'deleted'), + 'for', cbId), + cb = D.attr(D.input('checkbox'), 'id', cbId); + cb.checked = false; + D.addClass(parentElem,'hide-deleted'); + D.attr(lbl); + const deletedTip = F.helpButtonlets.create( + D.span(), + 'Fossil considers empty pages to be "deleted" in some contexts.' + ); + D.append(fsFilterBody, D.append( + D.span(), cb, lbl, deletedTip + )); + cb.addEventListener( + 'change', + function(ev){ + if(ev.target.checked) D.removeClass(parentElem,'hide-deleted'); + else D.addClass(parentElem,'hide-deleted'); + }, + false); + } + /* A legend of the meanings of the symbols we use in + the OPTION elements to denote certain state. */ + const fsLegend = D.fieldset("Edit status"), + fsLegendBody = D.div(); + D.append(fsLegend, fsLegendBody); + D.addClass(fsLegendBody, 'flex-container', 'flex-column', 'stretch'); + D.append( + fsLegendBody, + D.append(D.span(), getEditMarker(getEditMarker.NEW,false)," = new/unsaved"), + D.append(D.span(), getEditMarker(getEditMarker.MODIFIED,false)," = has local edits"), + D.append(D.span(), getEditMarker(getEditMarker.DELETED,false)," = is empty (deleted)") + ); + + const fsNewPage = D.fieldset("Create new page"), + fsNewPageBody = D.div(), + newPageName = D.input('text'), + newPageBtn = D.button("Add page locally") + ; + D.append(parentElem, fsNewPage); + D.append(fsNewPage, fsNewPageBody); + D.addClass(fsNewPageBody, 'flex-container', 'flex-column', 'new-page'); + D.append( + fsNewPageBody, newPageName, newPageBtn, + D.append(D.addClass(D.span(), 'mini-tip'), + "New pages exist only in this browser until they are saved.") + ); + newPageBtn.addEventListener('click', function(){ + if(self.addNewPage(newPageName.value)){ + newPageName.value = ''; + } + }, false); + + D.append( + parentElem, + D.append(D.addClass(D.div(), 'fieldset-wrapper'), + fsFilter, fsNewPage, fsLegend) + ); + + D.append(parentElem, btn); + btn.addEventListener('click', ()=>this.loadList(), false); + this.loadList(); + const onSelect = (e)=>P.loadPage(e.target.value); + sel.addEventListener('change', onSelect, false); + sel.addEventListener('dblclick', onSelect, false); + F.page.addEventListener('wiki-stash-updated', ()=>{ + if(P.winfo) this._refreshStashMarks(); + else this._rebuildList(); + }); + F.page.addEventListener('wiki-page-loaded', function(ev){ + /* Needed to handle the saved-an-empty-page case. */ + const page = ev.detail, + opt = self.cache.optByName[page.name]; + if(opt){ + if(page.isEmpty) opt.dataset.isDeleted = true; + else delete opt.dataset.isDeleted; + self._refreshStashMarks(opt); + }else if('sandbox'!==page.type){ + F.error("BUG: internal mis-handling of page object: missing OPTION for page "+page.name); + } + }); + + const cbEditPreview = E('#edit-shift-enter-preview'); + cbEditPreview.addEventListener('change', function(e){ + F.storage.set('edit-shift-enter-preview', + P.config.shiftEnterPreview = e.target.checked); + }, false); + cbEditPreview.checked = P.config.shiftEnterPreview; + delete this.init; + }/*init()*/ + }; + + /** + Widget for listing and selecting $stash entries. + */ + P.stashWidget = { + e:{/*DOM element(s)*/}, + init: function(domInsertPoint/*insert widget BEFORE this element*/){ + const wrapper = D.addClass( + D.attr(D.div(),'id','wikiedit-stash-selector'), + 'input-with-label' + ); + const sel = this.e.select = D.select(), + btnClear = this.e.btnClear = D.button("Discard Edits"), + btnHelp = D.append( + D.addClass(D.div(), "help-buttonlet"), + 'Locally-edited wiki pages. Timestamps are the last local edit time. ', + 'Only the ',P.config.defaultMaxStashSize,' most recent pages ', + 'are retained. Saving or reloading a file removes it from this list. ', + D.append(D.code(),F.storage.storageImplName()), + ' = ',F.storage.storageHelpDescription() + ); + D.append(wrapper, "Local edits (", + D.append(D.code(), + F.storage.storageImplName()), + "):", + btnHelp, sel, btnClear); + F.helpButtonlets.setup(btnHelp); + D.option(D.disable(sel), undefined, "(empty)"); + P.addEventListener('wiki-stash-updated',(e)=>this.updateList(e.detail)); + P.addEventListener('wiki-page-loaded',(e)=>this.updateList($stash, e.detail)); + sel.addEventListener('change',function(e){ + const opt = this.selectedOptions[0]; + if(opt && opt._winfo) P.loadPage(opt._winfo); + }); + if(F.storage.isTransient()){/*Warn if our storage is particularly transient...*/ + D.append(wrapper, D.append( + D.addClass(D.span(),'warning'), + "Warning: persistent storage is not available, "+ + "so uncomitted edits will not survive a page reload." + )); + } + domInsertPoint.parentNode.insertBefore(wrapper, domInsertPoint); + if(P.config.useConfirmerButtons.discardStash){ + /* Must come after btnClear is in the DOM AND the button must + not be hidden, else pinned sizing won't work. */ + F.confirmer(btnClear, { + pinSize: true, + confirmText: "DISCARD all local edits?", + onconfirm: ()=>P.clearStash(), + ticks: F.config.confirmerButtonTicks + }); + }else{ + btnClear.addEventListener('click', ()=>P.clearStash(), false); + } + D.addClass(btnClear,'hidden'); + $stash._fireStashEvent(/*read the page-load-time stash*/); + delete this.init; + }, + /** + Regenerates the edit selection list. + */ + updateList: function f(stasher,theWinfo){ + if(!f.compare){ + const cmpBase = (l,r)=>l<r ? -1 : (l===r ? 0 : 1); + f.compare = (l,r)=>cmpBase(l.name.toLowerCase(), r.name.toLowerCase()); + f.rxZ = /\.\d+Z$/ /* ms and 'Z' part of date string */; + const pad=(x)=>(''+x).length>1 ? x : '0'+x; + f.timestring = function(d){ + return [ + d.getFullYear(),'-',pad(d.getMonth()+1/*sigh*/),'-',pad(d.getDate()), + '@',pad(d.getHours()),':',pad(d.getMinutes()) + ].join(''); + }; + } + const index = stasher.getIndex(), ilist = []; + Object.keys(index).forEach((winfo)=>{ + ilist.push(index[winfo]); + }); + const self = this; + D.clearElement(this.e.select); + if(0===ilist.length){ + D.addClass(this.e.btnClear, 'hidden'); + D.option(D.disable(this.e.select),undefined,"No local edits"); + return; + } + D.enable(this.e.select); + if(true){ + /* The problem with this Clear button is that it allows the + user to nuke a non-empty newly-added page without the + failsafe confirmation we have if they use + P.e.btnReload. Not yet sure how best to resolve that. */ + D.removeClass(this.e.btnClear, 'hidden'); + } + D.disable(D.option(this.e.select,undefined,"Select a local edit...")); + const currentWinfo = theWinfo || P.winfo || {name:''}; + ilist.sort(f.compare).forEach(function(winfo,n){ + const key = stasher.indexKey(winfo), + rev = winfo.version || ''; + const opt = D.option( + self.e.select, n+1/*value is (almost) irrelevant*/, + [winfo.name, + ' [', + rev ? F.hashDigits(rev) : ( + winfo.type==='sandbox' ? 'sandbox' : 'new/local' + ),'] ', + f.timestring(new Date(winfo.stashTime)) + ].join('') + ); + opt._winfo = winfo; + if(0===f.compare(currentWinfo, winfo)){ + D.attr(opt, 'selected', true); + } + }); + } + }/*P.stashWidget*/; + + /** + Keep track of how many in-flight AJAX requests there are so we + can disable input elements while any are pending. For + simplicity's sake we simply disable ALL OF IT while any AJAX is + pending, rather than disabling operation-specific UI elements, + which would be a huge maintenance hassle. + + Noting, however, that this global on/off is not *quite* + pedantically correct. Pedantically speaking. If an element is + disabled before an XHR starts, this code "should" notice that and + not include it in the to-re-enable list. That would be annoying + to do, and becomes impossible to do properly once multiple XHRs + are in transit and an element is disabled seprately between two + of those in-transit requests (that would be an unlikely, but + possible, corner case). + */ + const ajaxState = { + count: 0 /* in-flight F.fetch() requests */, + toDisable: undefined /* elements to disable during ajax activity */ + }; + F.fetch.beforesend = function f(){ + if(!ajaxState.toDisable){ + ajaxState.toDisable = document.querySelectorAll( + ['button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'fieldset:not([disabled])' + ].join(',') + ); + } + if(1===++ajaxState.count){ + D.addClass(document.body, 'waiting'); + D.disable(ajaxState.toDisable); + } + }; + F.fetch.aftersend = function(){ + if(0===--ajaxState.count){ + D.removeClass(document.body, 'waiting'); + D.enable(ajaxState.toDisable); + delete ajaxState.toDisable /* required to avoid enable/disable + race condition with the save button */; + } + }; + + F.onPageLoad(function() { + document.body.classList.add('wikiedit'); + P.base = {tag: E('base'), wikiUrl: F.repoUrl('wiki')}; + P.base.originalHref = P.base.tag.href; + P.e = { /* various DOM elements we work with... */ + taEditor: E('#wikiedit-content-editor'), + btnReload: E("#wikiedit-tab-content button.wikiedit-content-reload"), + btnSave: E("button.wikiedit-save"), + btnSaveClose: E("button.wikiedit-save-close"), + selectMimetype: E('select[name=mimetype]'), + selectFontSizeWrap: E('#select-font-size'), +// selectDiffWS: E('select[name=diff_ws]'), + cbAutoPreview: E('#cb-preview-autorefresh'), + previewTarget: E('#wikiedit-tab-preview-wrapper'), + diffTarget: E('#wikiedit-tab-diff-wrapper'), + editStatus: E('#wikiedit-edit-status'), + tabContainer: E('#wikiedit-tabs'), + attachmentContainer: E("#attachment-wrapper"), + tabs:{ + pageList: E('#wikiedit-tab-pages'), + content: E('#wikiedit-tab-content'), + preview: E('#wikiedit-tab-preview'), + diff: E('#wikiedit-tab-diff'), + misc: E('#wikiedit-tab-misc') + //commit: E('#wikiedit-tab-commit') + } + }; + P.tabs = new F.TabManager(D.clearElement(P.e.tabContainer)); + /* Move the status bar between the tab buttons and + tab panels. Seems to be the best fit in terms of + functionality and visibility. */ + P.tabs.addCustomWidget( E('#fossil-status-bar') ).addCustomWidget(P.e.editStatus); + let currentTab/*used for ctrl-enter switch between editor and preview*/; + P.tabs.addEventListener( + /* Set up some before-switch-to tab event tasks... */ + 'before-switch-to', function(ev){ + const theTab = currentTab = ev.detail, btnSlot = theTab.querySelector('.save-button-slot'); + if(btnSlot){ + /* Several places make sense for a save button, so we'll + move that button around to those tabs where it makes sense. */ + btnSlot.parentNode.insertBefore( P.e.btnSave.parentNode, btnSlot ); + btnSlot.parentNode.insertBefore( P.e.btnSaveClose.parentNode, btnSlot ); + P.updateSaveButton(); + } + if(theTab===P.e.tabs.preview){ + P.baseHrefForWiki(); + if(P.previewNeedsUpdate && P.e.cbAutoPreview.checked) P.preview(); + }else if(theTab===P.e.tabs.diff){ + /* Work around a weird bug where the page gets wider than + the window when the diff tab is NOT in view and the + current SBS diff widget is wider than the window. When + the diff IS in view then CSS overflow magically reduces + the page size again. Weird. Maybe FF-specific. Note that + this weirdness happens even though P.e.diffTarget's parent + is hidden (and therefore P.e.diffTarget is also hidden). + */ + D.removeClass(P.e.diffTarget, 'hidden'); + } + } + ); + P.tabs.addEventListener( + /* Set up auto-refresh of the preview tab... */ + 'before-switch-from', function(ev){ + const theTab = ev.detail; + if(theTab===P.e.tabs.preview){ + P.baseHrefRestore(); + }else if(theTab===P.e.tabs.diff){ + /* See notes in the before-switch-to handler. */ + D.addClass(P.e.diffTarget, 'hidden'); + } + } + ); + //////////////////////////////////////////////////////////// + // Trigger preview on Ctrl-Enter. This only works on the built-in + // editor widget, not a client-provided one. + P.e.taEditor.addEventListener('keydown',function(ev){ + if(P.config.shiftEnterPreview && ev.shiftKey && 13===ev.keyCode){ + ev.preventDefault(); + ev.stopPropagation(); + P.e.taEditor.blur(/*force change event, if needed*/); + P.tabs.switchToTab(P.e.tabs.preview); + if(!P.e.cbAutoPreview.checked){/* If NOT in auto-preview mode, trigger an update. */ + P.preview(); + } + } + }, false); + // If we're in the preview tab, have ctrl-enter switch back to the editor. + document.body.addEventListener('keydown',function(ev){ + if(ev.shiftKey && 13 === ev.keyCode){ + if(currentTab === P.e.tabs.preview){ + ev.preventDefault(); + ev.stopPropagation(); + P.tabs.switchToTab(P.e.tabs.content); + P.e.taEditor.focus(/*doesn't work for client-supplied editor widget! + And it's slow as molasses for long docs, as focus() + forces a document reflow. */); + //console.debug("BODY ctrl-enter"); + return false; + } + } + }, true); + + F.connectPagePreviewers( + P.e.tabs.preview.querySelector( + '#btn-preview-refresh' + ) + ); + + const diffButtons = E('#wikiedit-tab-diff-buttons'); + diffButtons.querySelector('button.sbs').addEventListener( + "click",(e)=>P.diff(true), false + ); + diffButtons.querySelector('button.unified').addEventListener( + "click",(e)=>P.diff(false), false + ); + if(0) P.e.btnCommit.addEventListener( + "click",(e)=>P.commit(), false + ); + const doSave = function(alsoClose){ + const w = P.winfo; + if(!w){ + F.error("No page loaded."); + return; + } + if(alsoClose){ + P.save(()=>window.location.href=F.repoUrl('wiki',{name: w.name})); + }else{ + P.save(); + } + }; + const doReload = function(e){ + const w = P.winfo; + if(!w){ + F.error("No page loaded."); + return; + } + if(!w.version/* new/unsaved page */ + && w.type!=='sandbox' + && P.wikiContent()){ + F.error("This new/unsaved page has content.", + "To really discard this page,", + "first clear its content", + "then use the Discard button."); + return; + } + P.unstashContent(); + if(w.version || w.type==='sandbox'){ + P.loadPage(w); + }else{ + WikiList.removeEntry(w.name); + delete P.winfo; + P.updatePageTitle(); + F.message("Discarded new page ["+w.name+"]."); + } + }; + + if(P.config.useConfirmerButtons.reload){ + P.tabs.switchToTab(1/*DOM visibility workaround*/); + F.confirmer(P.e.btnReload, { + pinSize: true, + confirmText: "Really reload, losing edits?", + onconfirm: doReload, + ticks: F.config.confirmerButtonTicks + }); + }else{ + P.e.btnReload.addEventListener('click', doReload, false); + } + if(P.config.useConfirmerButtons.save){ + P.tabs.switchToTab(1/*DOM visibility workaround*/); + F.confirmer(P.e.btnSave, { + pinSize: true, + confirmText: "Really save changes?", + onconfirm: ()=>doSave(), + ticks: F.config.confirmerButtonTicks + }); + F.confirmer(P.e.btnSaveClose, { + pinSize: true, + confirmText: "Really save changes?", + onconfirm: ()=>doSave(true), + ticks: F.config.confirmerButtonTicks + }); + }else{ + P.e.btnSave.addEventListener('click', ()=>doSave(), false); + P.e.btnSaveClose.addEventListener('click', ()=>doSave(true), false); + } + + P.e.taEditor.addEventListener('change', ()=>P.notifyOfChange(), false); + + P.selectMimetype(false, true); + P.e.selectMimetype.addEventListener( + 'change', + function(e){ + if(P.winfo && P.winfo.mimetype !== e.target.value){ + P.winfo.mimetype = e.target.value; + P._isDirty = true; + P.stashContentChange(true); + } + }, + false + ); + + const selectFontSize = E('select[name=editor_font_size]'); + if(selectFontSize){ + selectFontSize.addEventListener( + "change",function(e){ + const ed = P.e.taEditor; + ed.className = ed.className.replace( + /\bfont-size-\d+/g, '' ); + ed.classList.add('font-size-'+e.target.value); + }, false + ); + selectFontSize.dispatchEvent( + // Force UI update + new Event('change',{target:selectFontSize}) + ); + } + + P.addEventListener( + // Clear certain views when new content is loaded/set + 'wiki-content-replaced', + ()=>{ + P.previewNeedsUpdate = true; + D.clearElement(P.e.diffTarget, P.e.previewTarget); + } + ); + P.addEventListener( + // Clear certain views after a save + 'wiki-saved', + (e)=>{ + D.clearElement(P.e.diffTarget, P.e.previewTarget); + // TODO: replace preview with new content + } + ); + P.addEventListener('wiki-stash-updated',function(){ + /* MUST come before WikiList.init() and P.stashWidget.init() so + that interwoven event handlers get called in the right + order. */ + if(P.winfo && !P.winfo.version && !$stash.getWinfo(P.winfo)){ + // New local page was removed. + delete P.winfo; + P.wikiContent(''); + P.updatePageTitle(); + } + P.updateSaveButton(); + }).updatePageTitle().updateSaveButton(); + + P.addEventListener( + // Update various state on wiki page load + 'wiki-page-loaded', + function(ev){ + delete P._isDirty; + const winfo = ev.detail; + P.winfo = winfo; + P.previewNeedsUpdate = true; + P.e.selectMimetype.value = winfo.mimetype; + P.tabs.switchToTab(P.e.tabs.content); + P.wikiContent(winfo.content || ''); + WikiList.e.select.value = winfo.name; + if(!winfo.version && winfo.type!=='sandbox'){ + F.message('You are editing a new, unsaved page:',winfo.name); + } + P.updatePageTitle().updateSaveButton(/* b/c save() routes through here */); + }, + false + ); + /* These init()s need to come after P's event handlers are registered. + The tab-switching is a workaround for the pinSize option of the confirmer widgets: + it does not work if the confirmer button being initialized is in a hidden + part of the DOM :/. */ + P.tabs.switchToTab(0); + WikiList.init( P.e.tabs.pageList.firstElementChild ); + P.tabs.switchToTab(1); + P.stashWidget.init(P.e.tabs.content.lastElementChild); + P.tabs.switchToTab(0); + //P.$wikiList = WikiList/*only for testing/debugging*/; + }/*F.onPageLoad()*/); + + /** + Returns true if fossil.page.winfo is set, indicating that a page + has been loaded, else it reports an error and returns false. + + If passed a truthy value any error message about not having + a wiki page loaded is suppressed. + */ + const affirmPageLoaded = function(quiet){ + if(!P.winfo && !quiet) F.error("No wiki page is loaded."); + return !!P.winfo; + }; + + /** + Updates the attachments list from this.winfo. + */ + P.updateAttachmentsView = function f(){ + if(!f.eAttach){ + f.eAttach = P.e.attachmentContainer.querySelector('div'); + } + D.clearElement(f.eAttach); + const wi = this.winfo; + if(!wi){ + D.append(f.eAttach,"No page loaded."); + return this; + } + else if(!wi.version){ + D.append(f.eAttach, + "Page ["+wi.name+"] cannot have ", + "attachments until it is saved once."); + return this; + } + const btnReload = D.button("Reload list"); + const self = this; + btnReload.addEventListener('click', function(){ + const isStashed = $stash.hasStashedContent(wi); + F.fetch('wikiajax/attachments',{ + responseType: 'json', + urlParams: {page: wi.name}, + onload: function(r){ + wi.attachments = r; + if(isStashed) self.stashContentChange(true); + F.message("Reloaded attachment list for ["+wi.name+"]."); + self.updateAttachmentsView(); + } + }); + }); + if(!wi.attachments || !wi.attachments.length){ + D.append(f.eAttach, + btnReload, + " No attachments found for page ["+wi.name+"]. ", + D.a(F.repoUrl('attachadd',{ + target: wi.name, + from: F.repoUrl('wikiedit',{name: wi.name})}), + "Add attachments..." ) + ); + return this; + } + D.append( + f.eAttach, + D.append(D.p(), + btnReload," ", + D.a(F.repoUrl('attachlist',{page:wi.name}), + "Attachments for page ["+wi.name+"]"), + ". ", + D.a(F.repoUrl('attachadd',{ + target:wi.name, + from: F.repoUrl('wikiedit',{name: wi.name})}), + "Add attachments..." ) + ) + ); + wi.attachments.forEach(function(a){ + const wrap = D.div(); + D.append(f.eAttach, wrap); + D.append(wrap, + D.append(D.div(), + "Attachment ", + D.addClass( + D.a(F.repoUrl('ainfo',{name:a.uuid}), + F.hashDigits(a.uuid,true)), + 'monospace'), + " ", + a.filename, + (a.isLatest ? " (latest)" : "") + ) + ); + //D.append(wrap,D.append(D.div(), "URLs:")); + const ul = D.ul(); + D.append(wrap, ul); + [ // List download URL variants for each attachment: + [ + "attachdownload?page=", + encodeURIComponent(wi.name), + "&file=", + encodeURIComponent(a.filename) + ].join(''), + "raw/"+a.src + ].forEach(function(url){ + const imgUrl = D.append(D.addClass(D.span(), 'monospace'), url); + const urlCopy = D.button(); + const li = D.li(ul); + D.append(li, urlCopy, imgUrl); + F.copyButton(urlCopy, {copyFromElement: imgUrl}); + }); + }); + return this; + }; + + /** Updates the in-tab title/edit status information */ + P.updateEditStatus = function f(){ + if(!f.eLinks){ + f.eName = P.e.editStatus.querySelector('span.name'); + f.eLinks = P.e.editStatus.querySelector('span.links'); + } + const wi = this.winfo; + D.clearElement(f.eName, f.eLinks); + if(!wi){ + D.append(f.eName, '(no page loaded)'); + this.updateAttachmentsView(); + return this; + } + D.append(f.eName,getEditMarker(wi, false),wi.name); + this.updateAttachmentsView(); + if(!wi.version) return this; + D.append( + f.eLinks, + D.a(F.repoUrl('wiki',{name:wi.name}),"viewer"), + D.a(F.repoUrl('whistory',{name:wi.name}),'history'), + D.a(F.repoUrl('attachlist',{page:wi.name}),"attachments"), + D.a(F.repoUrl('attachadd',{page:wi.name,from: F.repoUrl('wikiedit',{name: wi.name})}), "attach"), + D.a(F.repoUrl('wikiedit',{name:wi.name}),"editor permalink") + ); + return this; + }; + + /** + Update the page title and header based on the state of + this.winfo. A no-op if this.winfo is not set. Returns this. + */ + P.updatePageTitle = function f(){ + if(!f.titleElement){ + f.titleElement = document.head.querySelector('title'); + } + const wi = P.winfo, marker = getEditMarker(wi, true), + title = wi ? wi.name : 'no page loaded'; + f.titleElement.innerText = 'Wiki Editor: ' + marker + title; + this.updateEditStatus(); + return this; + }; + + /** + Change the save button depending on whether we have stuff to save + or not. + */ + P.updateSaveButton = function(){ + /** + // Currently disabled, per forum feedback and platform-level + // event-handling compatibility, but might be revisited. We now + // use an is-dirty flag instead to prevent saving when no change + // event has fired for the current doc. + if(!this.winfo || !this.getStashedWinfo(this.winfo)){ + D.disable(this.e.btnSave).innerText = + "No changes to save"; + D.disable(this.e.btnSaveClose); + }else{ + D.enable(this.e.btnSave).innerText = "Save"; + D.enable(this.e.btnSaveClose); + }*/ + return this; + }; + + /** + Getter (if called with no args) or setter (if passed an arg) for + the current file content. + + The setter form sets the content, dispatches a + 'wiki-content-replaced' event, and returns this object. + */ + P.wikiContent = function f(){ + if(0===arguments.length){ + return f.get(); + }else{ + f.set(arguments[0] || ''); + this.dispatchEvent('wiki-content-replaced', this); + return this; + } + }; + /* Default get/set impls for file content */ + P.wikiContent.get = function(){return P.e.taEditor.value}; + P.wikiContent.set = function(content){P.e.taEditor.value = content}; + + /** + For use when installing a custom editor widget. Pass it the + getter and setter callbacks to fetch resp. set the content of the + custom widget. They will be triggered via + P.wikiContent(). Returns this object. + */ + P.setContentMethods = function(getter, setter){ + this.wikiContent.get = getter; + this.wikiContent.set = setter; + return this; + }; + + /** + Alerts the editor app that a "change" has happened in the editor. + When connecting 3rd-party editor widgets to this app, it is + necessary to call this for any "change" events the widget emits. + Whether or not "change" means that there were "really" edits is + irrelevant, but this app will not allow saving unless it believes + at least one "change" has been made (by being signaled through + this method). + + This function may perform an arbitrary amount of work, so it + should not be called for every keypress within the editor + widget. Calling it for "blur" events is generally sufficient, and + calling it for each Enter keypress is generally reasonable but + also computationally costly. + */ + P.notifyOfChange = function(){ + P._isDirty = true; + P.stashContentChange(); + }; + + /** + Removes the default editor widget (and any dependent elements) + from the DOM, adds the given element in its place, removes this + method from this object, and returns this object. This is not + needed if the 3rd-party widget replaces or hides this app's + editor widget (e.g. TinyMCE). + */ + P.replaceEditorElement = function(newEditor){ + P.e.taEditor.parentNode.insertBefore(newEditor, P.e.taEditor); + P.e.taEditor.remove(); + P.e.selectFontSizeWrap.remove(); + delete this.replaceEditorElement; + return P; + }; + + /** + Sets the current page's base.href to {g.zTop}/wiki. + */ + P.baseHrefForWiki = function f(){ + this.base.tag.href = this.base.wikiUrl; + return this; + }; + + /** + Sets the document's base.href value to its page-load-time + setting. + */ + P.baseHrefRestore = function(){ + this.base.tag.href = this.base.originalHref; + }; + + /** + loadPage() loads the given wiki page and updates the relevant + UI elements to reflect the loaded state. If passed no arguments + then it re-uses the values from the currently-loaded page, reloading + it (emitting an error message if no file is loaded). + + Returns this object, noting that the load is async. After loading + it triggers a 'wiki-page-loaded' event, passing it this.winfo. + + If a locally-edited copy of the given file/rev is found, that + copy is used instead of one fetched from the server, but it is + still treated as a load event. + + Alternate call forms: + + - no arguments: re-loads from this.winfo. + + - 1 non-string argument: assumed to be an winfo-style + object. Must have at least the {name} property, but need not have + other winfo state. + */ + P.loadPage = function(name){ + if(0===arguments.length){ + /* Reload from this.winfo */ + if(!affirmPageLoaded()) return this; + name = this.winfo.name; + }else if(1===arguments.length && 'string' !== typeof name){ + /* Assume winfo-like object */ + const arg = arguments[0]; + name = arg.name; + } + const onload = (r)=>{ + this.dispatchEvent('wiki-page-loaded', r); + }; + const stashWinfo = this.getStashedWinfo({name: name}); + if(stashWinfo){ // fake a response from the stash... + F.message("Fetched from the local-edit storage:", stashWinfo.name); + onload({ + name: stashWinfo.name, + mimetype: stashWinfo.mimetype, + type: stashWinfo.type, + version: stashWinfo.version, + parent: stashWinfo.parent, + isEmpty: !!stashWinfo.isEmpty, + content: $stash.stashedContent(stashWinfo), + attachments: stashWinfo.attachments + }); + this._isDirty = true/*b/c loading normally clears that flag*/; + return this; + } + F.message( + "Loading content..." + ).fetch('wikiajax/fetch',{ + urlParams: { + page: name + }, + responseType: 'json', + onload:(r)=>{ + F.message('Loaded page ['+r.name+'].'); + onload(r); + } + }); + return this; + }; + + /** + Fetches the page preview based on the contents and settings of + this page's input fields, and updates the UI with the + preview. + + Returns this object, noting that the operation is async. + */ + P.preview = function f(switchToTab){ + if(!affirmPageLoaded()) return this; + return this._postPreview(this.wikiContent(), function(c){ + P._previewTo(c); + if(switchToTab) self.tabs.switchToTab(self.e.tabs.preview); + }); + }; + + /** + Callback for use with F.connectPagePreviewers(). Gets passed + the preview content. + */ + P._previewTo = function(c){ + const target = this.e.previewTarget; + D.clearElement(target); + if('string'===typeof c) D.parseHtml(target,c); + if(F.pikchr){ + F.pikchr.addSrcView(target.querySelectorAll('svg.pikchr')); + } + }; + + /** + Callback for use with F.connectPagePreviewers() + */ + P._postPreview = function(content,callback){ + if(!affirmPageLoaded()) return this; + if(!content){ + callback(content); + return this; + } + const fd = new FormData(); + const mimetype = this.e.selectMimetype.value; + fd.append('page', this.winfo.name); + fd.append('mimetype',mimetype); + fd.append('content',content || ''); + F.message( + "Fetching preview..." + ).fetch('wikiajax/preview',{ + payload: fd, + onload: (r,header)=>{ + callback(r); + F.message('Updated preview.'); + P.previewNeedsUpdate = false; + P.dispatchEvent('wiki-preview-updated',{ + mimetype: mimetype, + element: P.e.previewTarget + }); + }, + onerror: (e)=>{ + F.fetch.onerror(e); + callback("Error fetching preview: "+e); + } + }); + return this; + }; + + /** + Fetches the content diff based on the contents and settings of + this page's input fields, and updates the UI with the diff view. + + Returns this object, noting that the operation is async. + */ + P.diff = function f(sbs){ + if(!affirmPageLoaded()) return this; + const content = this.wikiContent(), + self = this, + target = this.e.diffTarget; + const fd = new FormData(); + fd.append('page',this.winfo.name); + fd.append('sbs', sbs ? 1 : 0); + fd.append('content',content); + if(this.e.selectDiffWS) fd.append('ws',this.e.selectDiffWS.value); + F.message( + "Fetching diff..." + ).fetch('wikiajax/diff',{ + payload: fd, + onload: function(c){ + D.parseHtml(D.clearElement(target), [ + "<div>Diff <code>[", + self.winfo.name, + "]</code> → Local Edits</div>", + c||'No changes.' + ].join('')); + F.diff.setupDiffContextLoad(); + if(sbs) P.tweakSbsDiffs(); + F.message('Updated diff.'); + self.tabs.switchToTab(self.e.tabs.diff); + } + }); + return this; + }; + + /** + Saves the current wiki page and re-populates the editor + with the saved state. If passed an argument, it is + expected to be a function, which is called only if + saving succeeds, after all other post-save processing. + */ + P.save = function callee(onSuccessCallback){ + if(!affirmPageLoaded()) return this; + else if(!this._isDirty){ + F.error("There are no changes to save."); + return this; + } + const content = this.wikiContent(); + const self = this; + callee.onload = function(w){ + const oldWinfo = self.winfo; + self.unstashContent(oldWinfo); + self.dispatchEvent('wiki-page-loaded', w)/* will reset save buttons */; + F.message("Saved page: ["+w.name+"]."); + if('function'===typeof onSuccessCallback){ + onSuccessCallback(); + } + }; + const fd = new FormData(), w = P.winfo; + fd.append('page',w.name); + fd.append('mimetype', w.mimetype); + fd.append('isnew', w.version ? 0 : 1); + fd.append('content', P.wikiContent()); + F.message( + "Saving page..." + ).fetch('wikiajax/save',{ + payload: fd, + responseType: 'json', + onload: callee.onload + }); + return this; + }; + + /** + Updates P.winfo for certain state and stashes P.winfo, with the + current content fetched via P.wikiContent(). + + If passed truthy AND the stash already has stashed content for + the current page, only the stashed winfo record is updated, else + both the winfo and content are updated. + */ + P.stashContentChange = function(onlyWinfo){ + if(affirmPageLoaded(true)){ + const wi = this.winfo; + wi.mimetype = P.e.selectMimetype.value; + if(onlyWinfo && $stash.hasStashedContent(wi)){ + $stash.updateWinfo(wi); + }else{ + $stash.updateWinfo(wi, P.wikiContent()); + } + F.message("Stashed changes to page ["+wi.name+"]."); + P.updatePageTitle(); + $stash.prune(); + this.previewNeedsUpdate = true; + } + return this; + }; + + /** + Removes any stashed state for the current P.winfo (if set) from + F.storage. Returns this. + */ + P.unstashContent = function(){ + const winfo = arguments[0] || this.winfo; + if(winfo){ + this.previewNeedsUpdate = true; + $stash.unstash(winfo); + //console.debug("Unstashed",winfo); + F.message("Unstashed page ["+winfo.name+"]."); + } + return this; + }; + + /** + Clears all stashed file state from F.storage. Returns this. + */ + P.clearStash = function(){ + $stash.clear(); + return this; + }; + + /** + If stashed content for P.winfo exists, it is returned, else + undefined is returned. + */ + P.contentFromStash = function(){ + return affirmPageLoaded(true) ? $stash.stashedContent(this.winfo) : undefined; + }; + + /** + If a stashed version of the given winfo object exists (same + filename/checkin values), return it, else return undefined. + */ + P.getStashedWinfo = function(winfo){ + return $stash.getWinfo(winfo); + }; +})(window.fossil); |
