| | @@ -269,30 +269,44 @@ |
| 269 | 269 | // Force UI update |
| 270 | 270 | s.dispatchEvent(new Event('change',{target:s})); |
| 271 | 271 | } |
| 272 | 272 | }; |
| 273 | 273 | |
| 274 | + /** |
| 275 | + Sets up and maintains the widgets for the list of wiki pages. |
| 276 | + */ |
| 274 | 277 | const WikiList = { |
| 275 | 278 | e: { |
| 276 | 279 | filterCheckboxes: { |
| 277 | 280 | /*map of wiki page type to checkbox for list filtering purposes, |
| 278 | 281 | except for "sandbox" type, which is assumed to be covered by |
| 279 | | - the "normal" type filter. */} |
| 282 | + the "normal" type filter. */}, |
| 283 | + }, |
| 284 | + cache: { |
| 285 | + names: { |
| 286 | + /* Map of page names to "something." We don't map to their |
| 287 | + winfo bits because those regularly get swapped out via |
| 288 | + de/serialization. We need this map to support the add-new-page |
| 289 | + feature, to give us a way to check for dupes without asking |
| 290 | + the server or walking through the whole selection list. |
| 291 | + */} |
| 280 | 292 | }, |
| 281 | 293 | /** Updates OPTION elements to reflect whether the page has |
| 282 | 294 | local changes or is new/unsaved. */ |
| 283 | 295 | refreshStashMarks: function(){ |
| 284 | | - const sel = this.e.select; |
| 285 | | - Object.keys(sel.options).forEach(function(key){ |
| 286 | | - const opt = sel.options[key]; |
| 296 | + this.cache.names = {/*must reset it to acount for local page removals*/}; |
| 297 | + const select = this.e.select, self = this; |
| 298 | + Object.keys(select.options).forEach(function(key){ |
| 299 | + const opt = select.options[key]; |
| 287 | 300 | const stashed = $stash.getWinfo({name:opt.value}); |
| 288 | 301 | if(stashed){ |
| 289 | 302 | const isNew = 'sandbox'===stashed.type ? false : !stashed.version; |
| 290 | 303 | D.addClass(opt, isNew ? 'stashed-new' :'stashed'); |
| 291 | 304 | }else{ |
| 292 | 305 | D.removeClass(opt, 'stashed', 'stashed-new'); |
| 293 | 306 | } |
| 307 | + self.cache.names[opt.value] = true; |
| 294 | 308 | }); |
| 295 | 309 | }, |
| 296 | 310 | /** Removes the given wiki page entry from the page selection |
| 297 | 311 | list, if it's in the list. */ |
| 298 | 312 | removeEntry: function(name){ |
| | @@ -304,45 +318,59 @@ |
| 304 | 318 | sel.options.remove(sel.selectedIndex); |
| 305 | 319 | } |
| 306 | 320 | sel.selectedIndex = ndx; |
| 307 | 321 | }, |
| 308 | 322 | |
| 309 | | - /** Loads the page list and populates the selection list. */ |
| 310 | | - loadList: function callee(){ |
| 311 | | - delete this.pageMap; |
| 323 | + /** |
| 324 | + Rebuilds the selection list. Necessary when it's loaded from |
| 325 | + the server or we locally create a new page. */ |
| 326 | + _rebuildList: function callee(){ |
| 327 | + /* Jump through some hoops to integrate new/unsaved |
| 328 | + pages into the list of existing pages... We use a map |
| 329 | + as an intermediary in order to filter out any local-stash |
| 330 | + dupes from server-side copies. */ |
| 331 | + const list = this.cache.pageList; |
| 332 | + if(!list) return; |
| 312 | 333 | if(!callee.sorticase){ |
| 313 | 334 | callee.sorticase = function(l,r){ |
| 335 | + if(l===r) return 0; |
| 314 | 336 | l = l.toLowerCase(); |
| 315 | 337 | r = r.toLowerCase(); |
| 316 | 338 | return l<=r ? -1 : 1; |
| 317 | 339 | }; |
| 340 | + } |
| 341 | + const map = {}, ndx = $stash.getIndex(), sel = this.e.select; |
| 342 | + D.clearElement(sel); |
| 343 | + list.forEach((winfo)=>map[winfo.name] = winfo); |
| 344 | + Object.keys(ndx).forEach(function(key){ |
| 345 | + const winfo = ndx[key]; |
| 346 | + if(!winfo.version/*new page*/) map[winfo.name] = winfo; |
| 347 | + }); |
| 348 | + const self = this; |
| 349 | + Object.keys(map) |
| 350 | + .sort(callee.sorticase) |
| 351 | + .forEach(function(name){ |
| 352 | + const winfo = map[name]; |
| 353 | + const opt = D.option(sel, winfo.name); |
| 354 | + const wtype = opt.dataset.wtype = |
| 355 | + winfo.type==='sandbox' ? 'normal' : (winfo.type||'normal'); |
| 356 | + const cb = self.e.filterCheckboxes[wtype]; |
| 357 | + if(cb && !cb.checked) D.addClass(opt, 'hidden'); |
| 358 | + }); |
| 359 | + D.enable(sel); |
| 360 | + if(P.winfo) sel.value = P.winfo.name; |
| 361 | + this.refreshStashMarks(); |
| 362 | + }, |
| 363 | + |
| 364 | + /** Loads the page list and populates the selection list. */ |
| 365 | + loadList: function callee(){ |
| 366 | + delete this.pageMap; |
| 367 | + if(!callee.onload){ |
| 318 | 368 | const self = this; |
| 319 | 369 | callee.onload = function(list){ |
| 320 | | - /* Jump through some hoops to integrate new/unsaved |
| 321 | | - pages into the list of existing pages... We use a map |
| 322 | | - as an intermediary in order to filter out any local-stash |
| 323 | | - dupes from server-side copies. */ |
| 324 | | - const map = {}, ndx = $stash.getIndex(), sel = self.e.select; |
| 325 | | - D.clearElement(sel); |
| 326 | | - list.forEach((winfo)=>map[winfo.name] = winfo); |
| 327 | | - Object.keys(ndx).forEach(function(key){ |
| 328 | | - const winfo = ndx[key]; |
| 329 | | - if(!winfo.version/*new page*/) map[winfo.name] = winfo; |
| 330 | | - }); |
| 331 | | - Object.keys(map) |
| 332 | | - .sort(callee.sorticase) |
| 333 | | - .forEach(function(name){ |
| 334 | | - const winfo = map[name]; |
| 335 | | - const opt = D.option(sel, winfo.name); |
| 336 | | - const wtype = opt.dataset.wtype = |
| 337 | | - winfo.type==='sandbox' ? 'normal' : winfo.type; |
| 338 | | - const cb = self.e.filterCheckboxes[wtype]; |
| 339 | | - if(cb && !cb.checked) D.addClass(opt, 'hidden'); |
| 340 | | - }); |
| 341 | | - D.enable(sel); |
| 342 | | - if(P.winfo) sel.value = P.winfo.name; |
| 343 | | - self.refreshStashMarks(); |
| 370 | + self.cache.pageList = list; |
| 371 | + self._rebuildList(); |
| 344 | 372 | F.message("Loaded page list."); |
| 345 | 373 | }; |
| 346 | 374 | } |
| 347 | 375 | F.fetch('wikiajax/list',{ |
| 348 | 376 | urlParams:{verbose:true}, |
| | @@ -349,18 +377,75 @@ |
| 349 | 377 | responseType: 'json', |
| 350 | 378 | onload: callee.onload |
| 351 | 379 | }); |
| 352 | 380 | return this; |
| 353 | 381 | }, |
| 382 | + |
| 383 | + /** |
| 384 | + Returns true if the given name appears to be a valid |
| 385 | + wiki page name, noting that the final arbitrator is the |
| 386 | + server. On validation error it emits a message via fossil.error() |
| 387 | + and returns false. |
| 388 | + */ |
| 389 | + validatePageName: function(name){ |
| 390 | + var err; |
| 391 | + if(!name){ |
| 392 | + err = "may not be empty"; |
| 393 | + }else if(this.cache.names.hasOwnProperty(name)){ |
| 394 | + err = "page already exists: "+name; |
| 395 | + }else if(name.length>100){ |
| 396 | + err = "too long (limit is 100)"; |
| 397 | + }else if(/\s{2,}/.test(name)){ |
| 398 | + err = "multiple consecutive spaces"; |
| 399 | + }else if(/[\t\r\n]/.test(name)){ |
| 400 | + err = "contains control character(s)"; |
| 401 | + }else{ |
| 402 | + let i = 0, n = name.length, c; |
| 403 | + for( ; i < n; ++i ){ |
| 404 | + if(name.charCodeAt(i)<0x20){ |
| 405 | + err = "contains control character(s)"; |
| 406 | + break; |
| 407 | + } |
| 408 | + } |
| 409 | + } |
| 410 | + if(err){ |
| 411 | + F.error("Invalid name:",err); |
| 412 | + } |
| 413 | + return !err; |
| 414 | + }, |
| 415 | + |
| 416 | + /** |
| 417 | + If the given name is valid, a new page with that (trimmed) name |
| 418 | + is added to the local stash. |
| 419 | + */ |
| 420 | + addNewPage: function(name){ |
| 421 | + name = name.trim(); |
| 422 | + if(!this.validatePageName(name)) return false; |
| 423 | + var wtype = 'normal'; |
| 424 | + if(0===name.indexOf('checkin/')) wtype = 'checkin'; |
| 425 | + else if(0===name.indexOf('branch/')) wtype = 'branch'; |
| 426 | + else if(0===name.indexOf('tag/')) wtype = 'tag'; |
| 427 | + /* ^^^ note that we're not validating that, e.g., checkin/XYZ |
| 428 | + has a full artifact ID after "checkin/". */ |
| 429 | + const winfo = { |
| 430 | + name: name, type: wtype, mimetype: 'text/x-fossil-wiki', |
| 431 | + version: null, parent: null |
| 432 | + }; |
| 433 | + $stash.updateWinfo(winfo, ''); |
| 434 | + this._rebuildList(); |
| 435 | + P.loadPage(winfo.name); |
| 436 | + return true; |
| 437 | + }, |
| 438 | + |
| 354 | 439 | /** |
| 355 | 440 | Installs a wiki page selection list into the given parent DOM |
| 356 | 441 | element and loads the page list from the server. |
| 357 | 442 | */ |
| 358 | 443 | init: function(parentElem){ |
| 359 | | - const sel = D.select(), btn = D.button("Reload page list"); |
| 444 | + const sel = D.select(), btn = D.addClass(D.button("Reload page list"), 'save'); |
| 360 | 445 | this.e.select = sel; |
| 361 | | - D.addClass(parentElem, 'wikiedit-page-list-wrapper'); |
| 446 | + D.addClass(parentElem, 'WikiList'); |
| 362 | 447 | D.clearElement(parentElem); |
| 363 | 448 | D.append( |
| 364 | 449 | parentElem, |
| 365 | 450 | D.append(D.fieldset("Select a page to edit"), |
| 366 | 451 | sel) |
| | @@ -411,15 +496,36 @@ |
| 411 | 496 | D.append(D.span(), P.config.editStateMarkers.isModified, |
| 412 | 497 | " = page has local edits"), |
| 413 | 498 | D.append(D.span(), P.config.editStateMarkers.isNew, |
| 414 | 499 | " = page is new/unsaved") |
| 415 | 500 | ); |
| 501 | + |
| 502 | + const fsNewPage = D.fieldset("Create new page"), |
| 503 | + fsNewPageBody = D.div(), |
| 504 | + newPageName = D.input('text'), |
| 505 | + newPageBtn = D.button("Add page locally") |
| 506 | + ; |
| 507 | + D.append(parentElem, fsNewPage); |
| 508 | + D.append(fsNewPage, fsNewPageBody); |
| 509 | + D.addClass(fsNewPageBody, 'flex-container', 'flex-column', 'new-page'); |
| 510 | + D.append( |
| 511 | + fsNewPageBody, newPageName, newPageBtn, |
| 512 | + D.append(D.addClass(D.span(), 'mini-tip'), |
| 513 | + "New pages exist only in this browser until they are saved.") |
| 514 | + ); |
| 515 | + newPageBtn.addEventListener('click', function(){ |
| 516 | + if(self.addNewPage(newPageName.value)){ |
| 517 | + newPageName.value = ''; |
| 518 | + } |
| 519 | + }, false); |
| 520 | + |
| 416 | 521 | D.append( |
| 417 | 522 | parentElem, |
| 418 | 523 | D.append(D.addClass(D.div(), 'fieldset-wrapper'), |
| 419 | | - fsFilter, fsLegend) |
| 524 | + fsFilter, fsNewPage, fsLegend) |
| 420 | 525 | ); |
| 526 | + |
| 421 | 527 | D.append(parentElem, btn); |
| 422 | 528 | btn.addEventListener('click', ()=>this.loadList(), false); |
| 423 | 529 | this.loadList(); |
| 424 | 530 | sel.addEventListener('change', (e)=>P.loadPage(e.target.value), false); |
| 425 | 531 | F.page.addEventListener('wiki-stash-updated', ()=>this.refreshStashMarks(), false); |
| | @@ -502,11 +608,11 @@ |
| 502 | 608 | functionality and visibility. */ |
| 503 | 609 | E('#fossil-status-bar'), P.tabs.e.tabs |
| 504 | 610 | ); |
| 505 | 611 | |
| 506 | 612 | P.tabs.addEventListener( |
| 507 | | - /* Set up auto-refresh of the preview tab... */ |
| 613 | + /* Set up some before-switch-to tab event tasks... */ |
| 508 | 614 | 'before-switch-to', function(ev){ |
| 509 | 615 | if(ev.detail===P.e.tabs.preview){ |
| 510 | 616 | P.baseHrefForWiki(); |
| 511 | 617 | if(P.previewNeedsUpdate && P.e.cbAutoPreview.checked) P.preview(); |
| 512 | 618 | }else if(ev.detail===P.e.tabs.diff){ |
| | @@ -519,10 +625,11 @@ |
| 519 | 625 | is hidden (and therefore P.e.diffTarget is also hidden). |
| 520 | 626 | */ |
| 521 | 627 | D.removeClass(P.e.diffTarget, 'hidden'); |
| 522 | 628 | }else if(ev.detail===P.e.tabs.save){ |
| 523 | 629 | const btn = P.e.btnSave; |
| 630 | + P.updateAttachmentView(); |
| 524 | 631 | if(!P.winfo || !P.getStashedWinfo(P.winfo)){ |
| 525 | 632 | D.disable(btn).innerText = |
| 526 | 633 | "There are no changes to save"; |
| 527 | 634 | }else{ |
| 528 | 635 | D.enable(btn).innerText = "Save changes"; |
| | @@ -577,13 +684,13 @@ |
| 577 | 684 | } |
| 578 | 685 | P.unstashContent() |
| 579 | 686 | if(w.version || w.type==='sandbox'){ |
| 580 | 687 | P.loadPage(); |
| 581 | 688 | }else{ |
| 582 | | - delete P.winfo; |
| 583 | 689 | WikiList.removeEntry(w.name); |
| 584 | 690 | P.updatePageTitle(); |
| 691 | + delete P.winfo; |
| 585 | 692 | F.message("Discarded new page ["+w.name+"]."); |
| 586 | 693 | } |
| 587 | 694 | }, |
| 588 | 695 | ticks: 3 |
| 589 | 696 | }); |
| | @@ -662,15 +769,15 @@ |
| 662 | 769 | P.wikiContent(winfo.content || ''); |
| 663 | 770 | WikiList.e.select.value = winfo.name; |
| 664 | 771 | if(!winfo.version && winfo.type!=='sandbox'){ |
| 665 | 772 | F.error('You are editing a new, unsaved page:',winfo.name); |
| 666 | 773 | } |
| 667 | | - P.updateAttachmentView().updatePageTitle(); |
| 774 | + P.updatePageTitle(); |
| 668 | 775 | }, |
| 669 | 776 | false |
| 670 | 777 | ); |
| 671 | | - P.updateAttachmentView(); |
| 778 | + P.updatePageTitle().updateAttachmentView(); |
| 672 | 779 | }/*F.onPageLoad()*/); |
| 673 | 780 | |
| 674 | 781 | /** |
| 675 | 782 | Returns true if fossil.page.winfo is set, indicating that a page |
| 676 | 783 | has been loaded, else it reports an error and returns false. |
| | @@ -711,14 +818,16 @@ |
| 711 | 818 | const wrapper = P.e.attachmentWrapper; |
| 712 | 819 | D.clearElement(wrapper); |
| 713 | 820 | const ul = D.ul(); |
| 714 | 821 | D.append(wrapper, ul); |
| 715 | 822 | if(!P.winfo){ |
| 716 | | - D.append(D.li(ul), "No page loaded."); |
| 823 | + D.append(D.li(ul), |
| 824 | + "Load a page to get access to its attachment-related pages."); |
| 717 | 825 | return this; |
| 718 | 826 | }else if(!P.winfo.version){ |
| 719 | | - D.append(D.li(ul), "A new/unsaved page cannot have attachments."); |
| 827 | + D.append(D.li(ul), |
| 828 | + "A new/unsaved page cannot have attachments. Save it first."); |
| 720 | 829 | return this; |
| 721 | 830 | } |
| 722 | 831 | const wi = P.winfo; |
| 723 | 832 | D.append( |
| 724 | 833 | D.li(ul), |
| 725 | 834 | |