Fossil SCM

fossil-scm / src / fossil.page.wikiedit.js
Blame History Raw 1661 lines
1
(function(F/*the fossil object*/){
2
"use strict";
3
/**
4
Client-side implementation of the /wikiedit app. Requires that
5
the fossil JS bootstrapping is complete and that several fossil
6
JS APIs have been installed: fossil.fetch, fossil.dom,
7
fossil.tabs, fossil.storage, fossil.confirmer, fossil.popupwidget.
8
9
Custom events which can be listened for via
10
fossil.page.addEventListener():
11
12
- Event 'wiki-page-loaded': passes on information when it
13
loads a wiki (whether from the network or its internal local-edit
14
cache), in the form of an "winfo" object:
15
16
{
17
name: string,
18
mimetype: mimetype string,
19
type: "normal" | "tag" | "checkin" | "branch" | "ticket" | "sandbox",
20
version: UUID string or null for a sandbox page or new page,
21
parent: parent UUID string or null if no parent,
22
isEmpty: true if page has no content (is "deleted").
23
content: string, optional in most contexts
24
}
25
26
The internal docs and code frequently use the term "winfo", and such
27
references refer to an object with that form.
28
29
The fossil.page.wikiContent() method gets or sets the current
30
file content for the page.
31
32
- Event 'wiki-saved': is fired when a commit completes,
33
passing on the same info as wiki-page-loaded.
34
35
- Event 'wiki-content-replaced': when the editor's content is
36
replaced, as opposed to it being edited via user
37
interaction. This normally happens via selecting a file to
38
load. The event detail is the fossil.page object, not the current
39
file content.
40
41
- Event 'wiki-preview-updated': when the preview is refreshed
42
from the server, this event passes on information about the preview
43
change in the form of an object:
44
45
{
46
element: the DOM element which contains the content preview.
47
mimetype: the page's mimetype.
48
}
49
50
Here's an example which can be used with the highlightjs code
51
highlighter to update the highlighting when the preview is
52
refreshed in "wiki" mode (which includes fossil-native wiki and
53
markdown):
54
55
fossil.page.addEventListener(
56
'wiki-preview-updated',
57
(ev)=>{
58
if(ev.detail.mimetype!=='text/plain'){
59
ev.detail.element.querySelectorAll(
60
'code[class^=language-]'
61
).forEach((e)=>hljs.highlightBlock(e));
62
}
63
}
64
);
65
*/
66
const E = (s)=>document.querySelector(s),
67
D = F.dom,
68
P = F.page;
69
P.config = {
70
/* Max number of locally-edited pages to stash, after which we
71
drop the least-recently used. */
72
defaultMaxStashSize: 10,
73
useConfirmerButtons:{
74
/* If true during fossil.page setup, certain buttons will use a
75
"confirmer" step, else they will not. The confirmer topic has
76
been the source of much contention in the forum. */
77
save: false,
78
reload: true,
79
discardStash: true
80
},
81
/**
82
If true, a keyboard combo of shift-enter (from the editor)
83
toggles between preview and edit modes. This is normally
84
desired but at least one software keyboard is known to
85
misinteract with this, treating an Enter after
86
automatically-capitalized letters as a shift-enter:
87
88
https://fossil-scm.org/forum/forumpost/dbd5b68366147ce8
89
90
Maintenance note: /fileedit also uses this same key for the
91
same purpose.
92
*/
93
shiftEnterPreview: F.storage.getBool('edit-shift-enter-preview', true)
94
};
95
96
/**
97
$stash is an internal-use-only object for managing "stashed"
98
local edits, to help avoid that users accidentally lose content
99
by switching tabs or following links or some such. The basic
100
theory of operation is...
101
102
All "stashed" state is stored using fossil.storage.
103
104
- When the current wiki content is modified by the user, the
105
current state of the page is stashed.
106
107
- When saving, the stashed entry for the previous version is
108
removed from the stash.
109
110
- When "loading", we use any stashed state for the given
111
checkin/file combination. When forcing a re-load of content,
112
any stashed entry for that combination is removed from the
113
stash.
114
115
- Every time P.stashContentChange() updates the stash, it is
116
pruned to $stash.prune.defaultMaxCount most-recently-updated
117
entries.
118
119
- This API often refers to "winfo objects." Those are objects
120
with a minimum of {page,mimetype} properties (which must be
121
valid), and the page name is used as basis for the stash keys
122
for any given page.
123
124
The structure of the stash is a bit convoluted for efficiency's
125
sake: we store a map of file info (winfo) objects separately from
126
those files' contents because otherwise we would be required to
127
JSONize/de-JSONize the file content when stashing/restoring it,
128
and that would be horribly inefficient (meaning "battery-consuming"
129
on mobile devices).
130
*/
131
const $stash = {
132
keys: {
133
index: F.page.name+'.index'
134
},
135
/**
136
index: {
137
"PAGE_NAME": {wiki page info w/o content}
138
...
139
}
140
141
In F.storage we...
142
143
- Store this.index under the key this.keys.index.
144
145
- Store each page's content under the key
146
(P.name+'/PAGE_NAME'). These are stored separately from the
147
index entries to avoid having to JSONize/de-JSONize the
148
content. The assumption/hope is that the browser can store
149
those records "directly," without any intermediary
150
encoding/decoding going on.
151
*/
152
indexKey: function(winfo){return winfo.name},
153
/** Returns the key for storing content for the given key suffix,
154
by prepending P.name to suffix. */
155
contentKey: function(suffix){return P.name+'/'+suffix},
156
/** Returns the index object, fetching it from the stash or creating
157
it anew on the first call. */
158
getIndex: function(){
159
if(!this.index){
160
this.index = F.storage.getJSON(
161
this.keys.index, {}
162
);
163
}
164
return this.index;
165
},
166
_fireStashEvent: function(){
167
if(this._disableNextEvent) delete this._disableNextEvent;
168
else F.page.dispatchEvent('wiki-stash-updated', this);
169
},
170
/**
171
Returns the stashed version, if any, for the given winfo object.
172
*/
173
getWinfo: function(winfo){
174
const ndx = this.getIndex();
175
return ndx[this.indexKey(winfo)];
176
},
177
/** Serializes this object's index to F.storage. Returns this. */
178
storeIndex: function(){
179
if(this.index) F.storage.setJSON(this.keys.index,this.index);
180
return this;
181
},
182
/** Updates the stash record for the given winfo
183
and (optionally) content. If passed 1 arg, only
184
the winfo stash is updated, else both the winfo
185
and its contents are (re-)stashed. Returns this.
186
*/
187
updateWinfo: function(winfo,content){
188
const ndx = this.getIndex(),
189
key = this.indexKey(winfo),
190
old = ndx[key];
191
const record = old || (ndx[key]={
192
name: winfo.name
193
});
194
record.mimetype = winfo.mimetype;
195
record.type = winfo.type;
196
record.parent = winfo.parent;
197
record.version = winfo.version;
198
record.stashTime = new Date().getTime();
199
record.isEmpty = !!winfo.isEmpty;
200
record.attachments = winfo.attachments;
201
this.storeIndex();
202
if(arguments.length>1){
203
if(content) delete record.isEmpty;
204
F.storage.set(this.contentKey(key), content);
205
}
206
this._fireStashEvent();
207
return this;
208
},
209
/**
210
Returns the stashed content, if any, for the given winfo
211
object.
212
*/
213
stashedContent: function(winfo){
214
return F.storage.get(this.contentKey(this.indexKey(winfo)));
215
},
216
/** Returns true if we have stashed content for the given winfo
217
record or page name. */
218
hasStashedContent: function(winfo){
219
if('string'===typeof winfo) winfo = {name: winfo};
220
return F.storage.contains(this.contentKey(this.indexKey(winfo)));
221
},
222
/** Unstashes the given winfo record and its content.
223
Returns this. */
224
unstash: function(winfo){
225
const ndx = this.getIndex(),
226
key = this.indexKey(winfo);
227
delete winfo.stashTime;
228
delete ndx[key];
229
F.storage.remove(this.contentKey(key));
230
this.storeIndex();
231
this._fireStashEvent();
232
return this;
233
},
234
/**
235
Clears all $stash entries from F.storage. Returns this.
236
*/
237
clear: function(){
238
const ndx = this.getIndex(),
239
self = this;
240
let count = 0;
241
Object.keys(ndx).forEach(function(k){
242
++count;
243
const e = ndx[k];
244
delete ndx[k];
245
F.storage.remove(self.contentKey(k));
246
});
247
F.storage.remove(this.keys.index);
248
delete this.index;
249
if(count) this._fireStashEvent();
250
return this;
251
},
252
/**
253
Removes all but the maxCount most-recently-updated stash
254
entries, where maxCount defaults to this.prune.defaultMaxCount.
255
*/
256
prune: function f(maxCount){
257
const ndx = this.getIndex();
258
const li = [];
259
if(!maxCount || maxCount<0) maxCount = f.defaultMaxCount;
260
Object.keys(ndx).forEach((k)=>li.push(ndx[k]));
261
li.sort((l,r)=>l.stashTime - r.stashTime);
262
let n = 0;
263
while(li.length>maxCount){
264
++n;
265
const e = li.shift();
266
this._disableNextEvent = true;
267
this.unstash(e);
268
console.warn("Pruned oldest local file edit entry:",e);
269
}
270
if(n) this._fireStashEvent();
271
}
272
};
273
$stash.prune.defaultMaxCount = P.config.defaultMaxStashSize || 10;
274
P.$stash = $stash /* we have to expose this for the new-page case :/ */;
275
276
/**
277
Internal workaround to select the current preview mode
278
and fire a change event if the value actually changes
279
or if forceEvent is truthy.
280
*/
281
P.selectMimetype = function(modeValue, forceEvent){
282
const s = this.e.selectMimetype;
283
if(!modeValue) modeValue = s.value;
284
else if(s.value != modeValue){
285
s.value = modeValue;
286
forceEvent = true;
287
}
288
if(forceEvent){
289
// Force UI update
290
s.dispatchEvent(new Event('change',{target:s}));
291
}
292
};
293
294
/**
295
Internal helper to get an edit status indicator for the given
296
winfo object. Pass it a winfo object or one of the "constants"
297
which are assigned as member properties of this function (see
298
below its definition).
299
*/
300
const getEditMarker = function f(winfo, textOnly){
301
const esm = F.config.editStateMarkers;
302
if(f.NEW===winfo){ /* force is-new */
303
return textOnly ? esm.isNew :
304
D.addClass(D.append(D.span(),esm.isNew), 'is-new');
305
}else if(f.MODIFIED===winfo){ /* force is-modified */
306
return textOnly ? esm.isModified :
307
D.addClass(D.append(D.span(),esm.isModified), 'is-modified');
308
}else if(f.DELETED===winfo){/* force is-deleted */
309
return textOnly ? esm.isDeleted :
310
D.addClass(D.append(D.span(),esm.isDeleted), 'is-deleted');
311
}else if(winfo && winfo.version){ /* is existing page modified? */
312
if($stash.getWinfo(winfo)){
313
return textOnly ? esm.isModified :
314
D.addClass(D.append(D.span(),esm.isModified), 'is-modified');
315
}
316
/*fall through*/
317
}
318
else if(winfo){ /* is new non-sandbox or is modified sandbox? */
319
if('sandbox'!==winfo.type){
320
return textOnly ? esm.isNew :
321
D.addClass(D.append(D.span(),esm.isNew), 'is-new');
322
}else if($stash.getWinfo(winfo)){
323
return textOnly ? esm.isModified :
324
D.addClass(D.append(D.span(),esm.isModified), 'is-modified');
325
}
326
}
327
return textOnly ? '' : D.span();
328
};
329
getEditMarker.NEW = 1;
330
getEditMarker.MODIFIED = 2;
331
getEditMarker.DELETED = 3;
332
333
/**
334
Returns undefined if winfo is falsy, true if the given winfo
335
object appears to be "new", else returns false.
336
*/
337
const winfoIsNew = function(winfo){
338
if(!winfo) return undefined;
339
else if('sandbox' === winfo.type) return false;
340
else return !winfo.version;
341
};
342
343
/**
344
Sets up and maintains the widgets for the list of wiki pages.
345
*/
346
const WikiList = {
347
e: {
348
filterCheckboxes: {
349
/*map of wiki page type to checkbox for list filtering purposes,
350
except for "sandbox" type, which is assumed to be covered by
351
the "normal" type filter. */},
352
},
353
cache: {
354
pageList: [],
355
optByName:{/*map of page names to OPTION object, to speed up
356
certain operations.*/},
357
names: {
358
/* Map of page names to "something." We don't map to their
359
winfo bits because those regularly get swapped out via
360
de/serialization. We need this map to support the add-new-page
361
feature, to give us a way to check for dupes without asking
362
the server or walking through the whole selection list.
363
*/}
364
},
365
/**
366
Updates OPTION elements to reflect whether the page has local
367
changes or is new/unsaved. This implementation is horribly
368
inefficient, in that we have to walk and validate the whole
369
list for each stash-level change.
370
371
If passed an argument, it is assumed to be an OPTION element
372
and only that element is updated, else all OPTION elements
373
in this.e.select are updated.
374
375
Reminder to self: in order to mark is-edited/is-new state we
376
have to update the OPTION element's inner text to reflect the
377
is-modified/is-new flags, rather than use CSS classes to tag
378
them, because mobile Chrome can neither restyle OPTION elements
379
no render ::before content on them. We *also* use CSS tags, but
380
they aren't sufficient for the mobile browsers.
381
*/
382
_refreshStashMarks: function callee(option){
383
if(!callee.eachOpt){
384
const self = this;
385
callee.eachOpt = function(keyOrOpt){
386
const opt = 'string'===typeof keyOrOpt ? self.e.select.options[keyOrOpt] : keyOrOpt;
387
const stashed = $stash.getWinfo({name:opt.value});
388
var prefix = '';
389
D.removeClass(opt, 'stashed', 'stashed-new', 'deleted');
390
if(stashed){
391
const isNew = winfoIsNew(stashed);
392
prefix = getEditMarker(isNew ? getEditMarker.NEW : getEditMarker.MODIFIED, true);
393
D.addClass(opt, isNew ? 'stashed-new' : 'stashed');
394
D.removeClass(opt, 'deleted');
395
}else if(opt.dataset.isDeleted){
396
prefix = getEditMarker(getEditMarker.DELETED,true);
397
D.addClass(opt, 'deleted');
398
}
399
opt.innerText = prefix + opt.value;
400
self.cache.names[opt.value] = true;
401
};
402
}
403
if(arguments.length){
404
callee.eachOpt(option);
405
}else{
406
this.cache.names = {/*must reset it to acount for local page removals*/};
407
Object.keys(this.e.select.options).forEach(callee.eachOpt);
408
}
409
},
410
/** Removes the given wiki page entry from the page selection
411
list, if it's in the list. */
412
removeEntry: function(name){
413
const sel = this.e.select;
414
var ndx = sel.selectedIndex;
415
sel.value = name;
416
if(sel.selectedIndex>-1){
417
if(ndx === sel.selectedIndex) ndx = -1;
418
sel.options.remove(sel.selectedIndex);
419
}
420
sel.selectedIndex = ndx;
421
delete this.cache.names[name];
422
delete this.cache.optByName[name];
423
this.cache.pageList = this.cache.pageList.filter((wi)=>name !== wi.name);
424
},
425
426
/**
427
Rebuilds the selection list. Necessary when it's loaded from
428
the server, we locally create a new page, or we remove a
429
locally-created new page.
430
*/
431
_rebuildList: function callee(){
432
/* Jump through some hoops to integrate new/unsaved
433
pages into the list of existing pages... We use a map
434
as an intermediary in order to filter out any local-stash
435
dupes from server-side copies. */
436
const list = this.cache.pageList;
437
if(!list) return;
438
if(!callee.sorticase){
439
callee.sorticase = function(l,r){
440
if(l===r) return 0;
441
l = l.toLowerCase();
442
r = r.toLowerCase();
443
return l<=r ? -1 : 1;
444
};
445
}
446
const map = {}, ndx = $stash.getIndex(), sel = this.e.select;
447
D.clearElement(sel);
448
list.forEach((winfo)=>map[winfo.name] = winfo);
449
Object.keys(ndx).forEach(function(key){
450
const winfo = ndx[key];
451
if(!winfo.version/*new page*/) map[winfo.name] = winfo;
452
});
453
const self = this;
454
Object.keys(map)
455
.sort(callee.sorticase)
456
.forEach(function(name){
457
const winfo = map[name];
458
const opt = D.option(sel, winfo.name);
459
const wtype = opt.dataset.wtype =
460
winfo.type==='sandbox' ? 'normal' : (winfo.type||'normal');
461
const cb = self.e.filterCheckboxes[wtype];
462
self.cache.optByName[winfo.name] = opt;
463
if(cb && !cb.checked) D.addClass(opt, 'hidden');
464
if(winfo.isEmpty){
465
opt.dataset.isDeleted = true;
466
}
467
self._refreshStashMarks(opt);
468
});
469
D.enable(sel);
470
if(P.winfo) sel.value = P.winfo.name;
471
},
472
473
/** Loads the page list and populates the selection list. */
474
loadList: function callee(){
475
if(!callee.onload){
476
const self = this;
477
callee.onload = function(list){
478
self.cache.pageList = list;
479
self._rebuildList();
480
F.message("Loaded page list.");
481
};
482
}
483
if(P.initialPageList){
484
/* ^^^ injected at page-creation time. */
485
const list = P.initialPageList;
486
delete P.initialPageList;
487
callee.onload(list);
488
}else{
489
F.fetch('wikiajax/list',{
490
urlParams:{verbose:true},
491
responseType: 'json',
492
onload: callee.onload
493
});
494
}
495
return this;
496
},
497
498
/**
499
Returns true if the given name appears to be a valid
500
wiki page name, noting that the final arbitrator is the
501
server. On validation error it emits a message via fossil.error()
502
and returns false.
503
*/
504
validatePageName: function(name){
505
var err;
506
if(!name){
507
err = "may not be empty";
508
}else if(this.cache.names.hasOwnProperty(name)){
509
err = "page already exists: "+name;
510
}else if(name.length>100){
511
err = "too long (limit is 100)";
512
}else if(/\s{2,}/.test(name)){
513
err = "multiple consecutive spaces";
514
}else if(/[\t\r\n]/.test(name)){
515
err = "contains control character(s)";
516
}else{
517
let i = 0, n = name.length, c;
518
for( ; i < n; ++i ){
519
if(name.charCodeAt(i)<0x20){
520
err = "contains control character(s)";
521
break;
522
}
523
}
524
}
525
if(err){
526
F.error("Invalid name:",err);
527
}
528
return !err;
529
},
530
531
/**
532
If the given name is valid, a new page with that (trimmed) name
533
is added to the local stash.
534
*/
535
addNewPage: function(name){
536
name = name.trim();
537
if(!this.validatePageName(name)) return false;
538
var wtype = 'normal';
539
if(0===name.indexOf('checkin/')) wtype = 'checkin';
540
else if(0===name.indexOf('branch/')) wtype = 'branch';
541
else if(0===name.indexOf('ticket/')) wtype = 'ticket';
542
else if(0===name.indexOf('tag/')) wtype = 'tag';
543
/* ^^^ note that we're not validating that, e.g., checkin/XYZ
544
has a full artifact ID after "checkin/". */
545
const winfo = {
546
name: name, type: wtype, mimetype: 'text/x-markdown',
547
version: null, parent: null
548
};
549
this.cache.pageList.push(
550
winfo/*keeps entry from getting lost from the list on save*/
551
);
552
$stash.updateWinfo(winfo, '');
553
this._rebuildList();
554
P.loadPage(winfo.name);
555
return true;
556
},
557
558
/**
559
Installs a wiki page selection list into the given parent DOM
560
element and loads the page list from the server.
561
*/
562
init: function(parentElem){
563
const sel = D.select(), btn = D.addClass(D.button("Reload page list"), 'save');
564
this.e.select = sel;
565
D.addClass(parentElem, 'WikiList');
566
D.clearElement(parentElem);
567
D.append(
568
parentElem,
569
D.append(D.fieldset("Select a page to edit"),
570
sel)
571
);
572
D.attr(sel, 'size', 12);
573
D.option(D.disable(D.clearElement(sel)), undefined, "Loading...");
574
575
/** Set up filter checkboxes for the various types
576
of wiki pages... */
577
const fsFilter = D.addClass(D.fieldset("Page types"),"page-types-list"),
578
fsFilterBody = D.div(),
579
filters = ['normal', 'branch/...', 'tag/...', 'checkin/...', 'ticket/...']
580
;
581
D.append(fsFilter, fsFilterBody);
582
D.addClass(fsFilterBody, 'flex-container', 'flex-column', 'stretch');
583
584
// Add filters by page type...
585
const self = this;
586
const filterByType = function(wtype, show){
587
sel.querySelectorAll('option[data-wtype='+wtype+']').forEach(function(opt){
588
if(show) opt.classList.remove('hidden');
589
else opt.classList.add('hidden');
590
});
591
};
592
filters.forEach(function(label){
593
const wtype = label.split('/')[0];
594
const cbId = 'wtype-filter-'+wtype,
595
lbl = D.attr(D.append(D.label(),label),
596
'for', cbId),
597
cb = D.attr(D.input('checkbox'), 'id', cbId);
598
D.append(fsFilterBody, D.append(D.span(), cb, lbl));
599
self.e.filterCheckboxes[wtype] = cb;
600
cb.checked = true;
601
filterByType(wtype, cb.checked);
602
cb.addEventListener(
603
'change',
604
function(ev){filterByType(wtype, ev.target.checked)},
605
false
606
);
607
});
608
{ /* add filter for "deleted" pages */
609
const cbId = 'wtype-filter-deleted',
610
lbl = D.attr(D.append(D.label(),
611
getEditMarker(getEditMarker.DELETED,false),
612
'deleted'),
613
'for', cbId),
614
cb = D.attr(D.input('checkbox'), 'id', cbId);
615
cb.checked = false;
616
D.addClass(parentElem,'hide-deleted');
617
D.attr(lbl);
618
const deletedTip = F.helpButtonlets.create(
619
D.span(),
620
'Fossil considers empty pages to be "deleted" in some contexts.'
621
);
622
D.append(fsFilterBody, D.append(
623
D.span(), cb, lbl, deletedTip
624
));
625
cb.addEventListener(
626
'change',
627
function(ev){
628
if(ev.target.checked) D.removeClass(parentElem,'hide-deleted');
629
else D.addClass(parentElem,'hide-deleted');
630
},
631
false);
632
}
633
/* A legend of the meanings of the symbols we use in
634
the OPTION elements to denote certain state. */
635
const fsLegend = D.fieldset("Edit status"),
636
fsLegendBody = D.div();
637
D.append(fsLegend, fsLegendBody);
638
D.addClass(fsLegendBody, 'flex-container', 'flex-column', 'stretch');
639
D.append(
640
fsLegendBody,
641
D.append(D.span(), getEditMarker(getEditMarker.NEW,false)," = new/unsaved"),
642
D.append(D.span(), getEditMarker(getEditMarker.MODIFIED,false)," = has local edits"),
643
D.append(D.span(), getEditMarker(getEditMarker.DELETED,false)," = is empty (deleted)")
644
);
645
646
const fsNewPage = D.fieldset("Create new page"),
647
fsNewPageBody = D.div(),
648
newPageName = D.input('text'),
649
newPageBtn = D.button("Add page locally")
650
;
651
D.append(parentElem, fsNewPage);
652
D.append(fsNewPage, fsNewPageBody);
653
D.addClass(fsNewPageBody, 'flex-container', 'flex-column', 'new-page');
654
D.append(
655
fsNewPageBody, newPageName, newPageBtn,
656
D.append(D.addClass(D.span(), 'mini-tip'),
657
"New pages exist only in this browser until they are saved.")
658
);
659
newPageBtn.addEventListener('click', function(){
660
if(self.addNewPage(newPageName.value)){
661
newPageName.value = '';
662
}
663
}, false);
664
665
D.append(
666
parentElem,
667
D.append(D.addClass(D.div(), 'fieldset-wrapper'),
668
fsFilter, fsNewPage, fsLegend)
669
);
670
671
D.append(parentElem, btn);
672
btn.addEventListener('click', ()=>this.loadList(), false);
673
this.loadList();
674
const onSelect = (e)=>P.loadPage(e.target.value);
675
sel.addEventListener('change', onSelect, false);
676
sel.addEventListener('dblclick', onSelect, false);
677
F.page.addEventListener('wiki-stash-updated', ()=>{
678
if(P.winfo) this._refreshStashMarks();
679
else this._rebuildList();
680
});
681
F.page.addEventListener('wiki-page-loaded', function(ev){
682
/* Needed to handle the saved-an-empty-page case. */
683
const page = ev.detail,
684
opt = self.cache.optByName[page.name];
685
if(opt){
686
if(page.isEmpty) opt.dataset.isDeleted = true;
687
else delete opt.dataset.isDeleted;
688
self._refreshStashMarks(opt);
689
}else if('sandbox'!==page.type){
690
F.error("BUG: internal mis-handling of page object: missing OPTION for page "+page.name);
691
}
692
});
693
694
const cbEditPreview = E('#edit-shift-enter-preview');
695
cbEditPreview.addEventListener('change', function(e){
696
F.storage.set('edit-shift-enter-preview',
697
P.config.shiftEnterPreview = e.target.checked);
698
}, false);
699
cbEditPreview.checked = P.config.shiftEnterPreview;
700
delete this.init;
701
}/*init()*/
702
};
703
704
/**
705
Widget for listing and selecting $stash entries.
706
*/
707
P.stashWidget = {
708
e:{/*DOM element(s)*/},
709
init: function(domInsertPoint/*insert widget BEFORE this element*/){
710
const wrapper = D.addClass(
711
D.attr(D.div(),'id','wikiedit-stash-selector'),
712
'input-with-label'
713
);
714
const sel = this.e.select = D.select(),
715
btnClear = this.e.btnClear = D.button("Discard Edits"),
716
btnHelp = D.append(
717
D.addClass(D.div(), "help-buttonlet"),
718
'Locally-edited wiki pages. Timestamps are the last local edit time. ',
719
'Only the ',P.config.defaultMaxStashSize,' most recent pages ',
720
'are retained. Saving or reloading a file removes it from this list. ',
721
D.append(D.code(),F.storage.storageImplName()),
722
' = ',F.storage.storageHelpDescription()
723
);
724
D.append(wrapper, "Local edits (",
725
D.append(D.code(),
726
F.storage.storageImplName()),
727
"):",
728
btnHelp, sel, btnClear);
729
F.helpButtonlets.setup(btnHelp);
730
D.option(D.disable(sel), undefined, "(empty)");
731
P.addEventListener('wiki-stash-updated',(e)=>this.updateList(e.detail));
732
P.addEventListener('wiki-page-loaded',(e)=>this.updateList($stash, e.detail));
733
sel.addEventListener('change',function(e){
734
const opt = this.selectedOptions[0];
735
if(opt && opt._winfo) P.loadPage(opt._winfo);
736
});
737
if(F.storage.isTransient()){/*Warn if our storage is particularly transient...*/
738
D.append(wrapper, D.append(
739
D.addClass(D.span(),'warning'),
740
"Warning: persistent storage is not available, "+
741
"so uncomitted edits will not survive a page reload."
742
));
743
}
744
domInsertPoint.parentNode.insertBefore(wrapper, domInsertPoint);
745
if(P.config.useConfirmerButtons.discardStash){
746
/* Must come after btnClear is in the DOM AND the button must
747
not be hidden, else pinned sizing won't work. */
748
F.confirmer(btnClear, {
749
pinSize: true,
750
confirmText: "DISCARD all local edits?",
751
onconfirm: ()=>P.clearStash(),
752
ticks: F.config.confirmerButtonTicks
753
});
754
}else{
755
btnClear.addEventListener('click', ()=>P.clearStash(), false);
756
}
757
D.addClass(btnClear,'hidden');
758
$stash._fireStashEvent(/*read the page-load-time stash*/);
759
delete this.init;
760
},
761
/**
762
Regenerates the edit selection list.
763
*/
764
updateList: function f(stasher,theWinfo){
765
if(!f.compare){
766
const cmpBase = (l,r)=>l<r ? -1 : (l===r ? 0 : 1);
767
f.compare = (l,r)=>cmpBase(l.name.toLowerCase(), r.name.toLowerCase());
768
f.rxZ = /\.\d+Z$/ /* ms and 'Z' part of date string */;
769
const pad=(x)=>(''+x).length>1 ? x : '0'+x;
770
f.timestring = function(d){
771
return [
772
d.getFullYear(),'-',pad(d.getMonth()+1/*sigh*/),'-',pad(d.getDate()),
773
'@',pad(d.getHours()),':',pad(d.getMinutes())
774
].join('');
775
};
776
}
777
const index = stasher.getIndex(), ilist = [];
778
Object.keys(index).forEach((winfo)=>{
779
ilist.push(index[winfo]);
780
});
781
const self = this;
782
D.clearElement(this.e.select);
783
if(0===ilist.length){
784
D.addClass(this.e.btnClear, 'hidden');
785
D.option(D.disable(this.e.select),undefined,"No local edits");
786
return;
787
}
788
D.enable(this.e.select);
789
if(true){
790
/* The problem with this Clear button is that it allows the
791
user to nuke a non-empty newly-added page without the
792
failsafe confirmation we have if they use
793
P.e.btnReload. Not yet sure how best to resolve that. */
794
D.removeClass(this.e.btnClear, 'hidden');
795
}
796
D.disable(D.option(this.e.select,undefined,"Select a local edit..."));
797
const currentWinfo = theWinfo || P.winfo || {name:''};
798
ilist.sort(f.compare).forEach(function(winfo,n){
799
const key = stasher.indexKey(winfo),
800
rev = winfo.version || '';
801
const opt = D.option(
802
self.e.select, n+1/*value is (almost) irrelevant*/,
803
[winfo.name,
804
' [',
805
rev ? F.hashDigits(rev) : (
806
winfo.type==='sandbox' ? 'sandbox' : 'new/local'
807
),'] ',
808
f.timestring(new Date(winfo.stashTime))
809
].join('')
810
);
811
opt._winfo = winfo;
812
if(0===f.compare(currentWinfo, winfo)){
813
D.attr(opt, 'selected', true);
814
}
815
});
816
}
817
}/*P.stashWidget*/;
818
819
/**
820
Keep track of how many in-flight AJAX requests there are so we
821
can disable input elements while any are pending. For
822
simplicity's sake we simply disable ALL OF IT while any AJAX is
823
pending, rather than disabling operation-specific UI elements,
824
which would be a huge maintenance hassle.
825
826
Noting, however, that this global on/off is not *quite*
827
pedantically correct. Pedantically speaking. If an element is
828
disabled before an XHR starts, this code "should" notice that and
829
not include it in the to-re-enable list. That would be annoying
830
to do, and becomes impossible to do properly once multiple XHRs
831
are in transit and an element is disabled seprately between two
832
of those in-transit requests (that would be an unlikely, but
833
possible, corner case).
834
*/
835
const ajaxState = {
836
count: 0 /* in-flight F.fetch() requests */,
837
toDisable: undefined /* elements to disable during ajax activity */
838
};
839
F.fetch.beforesend = function f(){
840
if(!ajaxState.toDisable){
841
ajaxState.toDisable = document.querySelectorAll(
842
['button:not([disabled])',
843
'input:not([disabled])',
844
'select:not([disabled])',
845
'textarea:not([disabled])',
846
'fieldset:not([disabled])'
847
].join(',')
848
);
849
}
850
if(1===++ajaxState.count){
851
D.addClass(document.body, 'waiting');
852
D.disable(ajaxState.toDisable);
853
}
854
};
855
F.fetch.aftersend = function(){
856
if(0===--ajaxState.count){
857
D.removeClass(document.body, 'waiting');
858
D.enable(ajaxState.toDisable);
859
delete ajaxState.toDisable /* required to avoid enable/disable
860
race condition with the save button */;
861
}
862
};
863
864
F.onPageLoad(function() {
865
document.body.classList.add('wikiedit');
866
P.base = {tag: E('base'), wikiUrl: F.repoUrl('wiki')};
867
P.base.originalHref = P.base.tag.href;
868
P.e = { /* various DOM elements we work with... */
869
taEditor: E('#wikiedit-content-editor'),
870
btnReload: E("#wikiedit-tab-content button.wikiedit-content-reload"),
871
btnSave: E("button.wikiedit-save"),
872
btnSaveClose: E("button.wikiedit-save-close"),
873
selectMimetype: E('select[name=mimetype]'),
874
selectFontSizeWrap: E('#select-font-size'),
875
// selectDiffWS: E('select[name=diff_ws]'),
876
cbAutoPreview: E('#cb-preview-autorefresh'),
877
previewTarget: E('#wikiedit-tab-preview-wrapper'),
878
diffTarget: E('#wikiedit-tab-diff-wrapper'),
879
editStatus: E('#wikiedit-edit-status'),
880
tabContainer: E('#wikiedit-tabs'),
881
attachmentContainer: E("#attachment-wrapper"),
882
tabs:{
883
pageList: E('#wikiedit-tab-pages'),
884
content: E('#wikiedit-tab-content'),
885
preview: E('#wikiedit-tab-preview'),
886
diff: E('#wikiedit-tab-diff'),
887
misc: E('#wikiedit-tab-misc')
888
//commit: E('#wikiedit-tab-commit')
889
}
890
};
891
P.tabs = new F.TabManager(D.clearElement(P.e.tabContainer));
892
/* Move the status bar between the tab buttons and
893
tab panels. Seems to be the best fit in terms of
894
functionality and visibility. */
895
P.tabs.addCustomWidget( E('#fossil-status-bar') ).addCustomWidget(P.e.editStatus);
896
let currentTab/*used for ctrl-enter switch between editor and preview*/;
897
P.tabs.addEventListener(
898
/* Set up some before-switch-to tab event tasks... */
899
'before-switch-to', function(ev){
900
const theTab = currentTab = ev.detail, btnSlot = theTab.querySelector('.save-button-slot');
901
if(btnSlot){
902
/* Several places make sense for a save button, so we'll
903
move that button around to those tabs where it makes sense. */
904
btnSlot.parentNode.insertBefore( P.e.btnSave.parentNode, btnSlot );
905
btnSlot.parentNode.insertBefore( P.e.btnSaveClose.parentNode, btnSlot );
906
P.updateSaveButton();
907
}
908
if(theTab===P.e.tabs.preview){
909
P.baseHrefForWiki();
910
if(P.previewNeedsUpdate && P.e.cbAutoPreview.checked) P.preview();
911
}else if(theTab===P.e.tabs.diff){
912
/* Work around a weird bug where the page gets wider than
913
the window when the diff tab is NOT in view and the
914
current SBS diff widget is wider than the window. When
915
the diff IS in view then CSS overflow magically reduces
916
the page size again. Weird. Maybe FF-specific. Note that
917
this weirdness happens even though P.e.diffTarget's parent
918
is hidden (and therefore P.e.diffTarget is also hidden).
919
*/
920
D.removeClass(P.e.diffTarget, 'hidden');
921
}
922
}
923
);
924
P.tabs.addEventListener(
925
/* Set up auto-refresh of the preview tab... */
926
'before-switch-from', function(ev){
927
const theTab = ev.detail;
928
if(theTab===P.e.tabs.preview){
929
P.baseHrefRestore();
930
}else if(theTab===P.e.tabs.diff){
931
/* See notes in the before-switch-to handler. */
932
D.addClass(P.e.diffTarget, 'hidden');
933
}
934
}
935
);
936
////////////////////////////////////////////////////////////
937
// Trigger preview on Ctrl-Enter. This only works on the built-in
938
// editor widget, not a client-provided one.
939
P.e.taEditor.addEventListener('keydown',function(ev){
940
if(P.config.shiftEnterPreview && ev.shiftKey && 13===ev.keyCode){
941
ev.preventDefault();
942
ev.stopPropagation();
943
P.e.taEditor.blur(/*force change event, if needed*/);
944
P.tabs.switchToTab(P.e.tabs.preview);
945
if(!P.e.cbAutoPreview.checked){/* If NOT in auto-preview mode, trigger an update. */
946
P.preview();
947
}
948
}
949
}, false);
950
// If we're in the preview tab, have ctrl-enter switch back to the editor.
951
document.body.addEventListener('keydown',function(ev){
952
if(ev.shiftKey && 13 === ev.keyCode){
953
if(currentTab === P.e.tabs.preview){
954
ev.preventDefault();
955
ev.stopPropagation();
956
P.tabs.switchToTab(P.e.tabs.content);
957
P.e.taEditor.focus(/*doesn't work for client-supplied editor widget!
958
And it's slow as molasses for long docs, as focus()
959
forces a document reflow. */);
960
//console.debug("BODY ctrl-enter");
961
return false;
962
}
963
}
964
}, true);
965
966
F.connectPagePreviewers(
967
P.e.tabs.preview.querySelector(
968
'#btn-preview-refresh'
969
)
970
);
971
972
const diffButtons = E('#wikiedit-tab-diff-buttons');
973
diffButtons.querySelector('button.sbs').addEventListener(
974
"click",(e)=>P.diff(true), false
975
);
976
diffButtons.querySelector('button.unified').addEventListener(
977
"click",(e)=>P.diff(false), false
978
);
979
if(0) P.e.btnCommit.addEventListener(
980
"click",(e)=>P.commit(), false
981
);
982
const doSave = function(alsoClose){
983
const w = P.winfo;
984
if(!w){
985
F.error("No page loaded.");
986
return;
987
}
988
if(alsoClose){
989
P.save(()=>window.location.href=F.repoUrl('wiki',{name: w.name}));
990
}else{
991
P.save();
992
}
993
};
994
const doReload = function(e){
995
const w = P.winfo;
996
if(!w){
997
F.error("No page loaded.");
998
return;
999
}
1000
if(!w.version/* new/unsaved page */
1001
&& w.type!=='sandbox'
1002
&& P.wikiContent()){
1003
F.error("This new/unsaved page has content.",
1004
"To really discard this page,",
1005
"first clear its content",
1006
"then use the Discard button.");
1007
return;
1008
}
1009
P.unstashContent();
1010
if(w.version || w.type==='sandbox'){
1011
P.loadPage(w);
1012
}else{
1013
WikiList.removeEntry(w.name);
1014
delete P.winfo;
1015
P.updatePageTitle();
1016
F.message("Discarded new page ["+w.name+"].");
1017
}
1018
};
1019
1020
if(P.config.useConfirmerButtons.reload){
1021
P.tabs.switchToTab(1/*DOM visibility workaround*/);
1022
F.confirmer(P.e.btnReload, {
1023
pinSize: true,
1024
confirmText: "Really reload, losing edits?",
1025
onconfirm: doReload,
1026
ticks: F.config.confirmerButtonTicks
1027
});
1028
}else{
1029
P.e.btnReload.addEventListener('click', doReload, false);
1030
}
1031
if(P.config.useConfirmerButtons.save){
1032
P.tabs.switchToTab(1/*DOM visibility workaround*/);
1033
F.confirmer(P.e.btnSave, {
1034
pinSize: true,
1035
confirmText: "Really save changes?",
1036
onconfirm: ()=>doSave(),
1037
ticks: F.config.confirmerButtonTicks
1038
});
1039
F.confirmer(P.e.btnSaveClose, {
1040
pinSize: true,
1041
confirmText: "Really save changes?",
1042
onconfirm: ()=>doSave(true),
1043
ticks: F.config.confirmerButtonTicks
1044
});
1045
}else{
1046
P.e.btnSave.addEventListener('click', ()=>doSave(), false);
1047
P.e.btnSaveClose.addEventListener('click', ()=>doSave(true), false);
1048
}
1049
1050
P.e.taEditor.addEventListener('change', ()=>P.notifyOfChange(), false);
1051
1052
P.selectMimetype(false, true);
1053
P.e.selectMimetype.addEventListener(
1054
'change',
1055
function(e){
1056
if(P.winfo && P.winfo.mimetype !== e.target.value){
1057
P.winfo.mimetype = e.target.value;
1058
P._isDirty = true;
1059
P.stashContentChange(true);
1060
}
1061
},
1062
false
1063
);
1064
1065
const selectFontSize = E('select[name=editor_font_size]');
1066
if(selectFontSize){
1067
selectFontSize.addEventListener(
1068
"change",function(e){
1069
const ed = P.e.taEditor;
1070
ed.className = ed.className.replace(
1071
/\bfont-size-\d+/g, '' );
1072
ed.classList.add('font-size-'+e.target.value);
1073
}, false
1074
);
1075
selectFontSize.dispatchEvent(
1076
// Force UI update
1077
new Event('change',{target:selectFontSize})
1078
);
1079
}
1080
1081
P.addEventListener(
1082
// Clear certain views when new content is loaded/set
1083
'wiki-content-replaced',
1084
()=>{
1085
P.previewNeedsUpdate = true;
1086
D.clearElement(P.e.diffTarget, P.e.previewTarget);
1087
}
1088
);
1089
P.addEventListener(
1090
// Clear certain views after a save
1091
'wiki-saved',
1092
(e)=>{
1093
D.clearElement(P.e.diffTarget, P.e.previewTarget);
1094
// TODO: replace preview with new content
1095
}
1096
);
1097
P.addEventListener('wiki-stash-updated',function(){
1098
/* MUST come before WikiList.init() and P.stashWidget.init() so
1099
that interwoven event handlers get called in the right
1100
order. */
1101
if(P.winfo && !P.winfo.version && !$stash.getWinfo(P.winfo)){
1102
// New local page was removed.
1103
delete P.winfo;
1104
P.wikiContent('');
1105
P.updatePageTitle();
1106
}
1107
P.updateSaveButton();
1108
}).updatePageTitle().updateSaveButton();
1109
1110
P.addEventListener(
1111
// Update various state on wiki page load
1112
'wiki-page-loaded',
1113
function(ev){
1114
delete P._isDirty;
1115
const winfo = ev.detail;
1116
P.winfo = winfo;
1117
P.previewNeedsUpdate = true;
1118
P.e.selectMimetype.value = winfo.mimetype;
1119
P.tabs.switchToTab(P.e.tabs.content);
1120
P.wikiContent(winfo.content || '');
1121
WikiList.e.select.value = winfo.name;
1122
if(!winfo.version && winfo.type!=='sandbox'){
1123
F.message('You are editing a new, unsaved page:',winfo.name);
1124
}
1125
P.updatePageTitle().updateSaveButton(/* b/c save() routes through here */);
1126
},
1127
false
1128
);
1129
/* These init()s need to come after P's event handlers are registered.
1130
The tab-switching is a workaround for the pinSize option of the confirmer widgets:
1131
it does not work if the confirmer button being initialized is in a hidden
1132
part of the DOM :/. */
1133
P.tabs.switchToTab(0);
1134
WikiList.init( P.e.tabs.pageList.firstElementChild );
1135
P.tabs.switchToTab(1);
1136
P.stashWidget.init(P.e.tabs.content.lastElementChild);
1137
P.tabs.switchToTab(0);
1138
//P.$wikiList = WikiList/*only for testing/debugging*/;
1139
}/*F.onPageLoad()*/);
1140
1141
/**
1142
Returns true if fossil.page.winfo is set, indicating that a page
1143
has been loaded, else it reports an error and returns false.
1144
1145
If passed a truthy value any error message about not having
1146
a wiki page loaded is suppressed.
1147
*/
1148
const affirmPageLoaded = function(quiet){
1149
if(!P.winfo && !quiet) F.error("No wiki page is loaded.");
1150
return !!P.winfo;
1151
};
1152
1153
/**
1154
Updates the attachments list from this.winfo.
1155
*/
1156
P.updateAttachmentsView = function f(){
1157
if(!f.eAttach){
1158
f.eAttach = P.e.attachmentContainer.querySelector('div');
1159
}
1160
D.clearElement(f.eAttach);
1161
const wi = this.winfo;
1162
if(!wi){
1163
D.append(f.eAttach,"No page loaded.");
1164
return this;
1165
}
1166
else if(!wi.version){
1167
D.append(f.eAttach,
1168
"Page ["+wi.name+"] cannot have ",
1169
"attachments until it is saved once.");
1170
return this;
1171
}
1172
const btnReload = D.button("Reload list");
1173
const self = this;
1174
btnReload.addEventListener('click', function(){
1175
const isStashed = $stash.hasStashedContent(wi);
1176
F.fetch('wikiajax/attachments',{
1177
responseType: 'json',
1178
urlParams: {page: wi.name},
1179
onload: function(r){
1180
wi.attachments = r;
1181
if(isStashed) self.stashContentChange(true);
1182
F.message("Reloaded attachment list for ["+wi.name+"].");
1183
self.updateAttachmentsView();
1184
}
1185
});
1186
});
1187
if(!wi.attachments || !wi.attachments.length){
1188
D.append(f.eAttach,
1189
btnReload,
1190
" No attachments found for page ["+wi.name+"]. ",
1191
D.a(F.repoUrl('attachadd',{
1192
page: wi.name,
1193
from: F.repoUrl('wikiedit',{name: wi.name})}),
1194
"Add attachments..." )
1195
);
1196
return this;
1197
}
1198
D.append(
1199
f.eAttach,
1200
D.append(D.p(),
1201
btnReload," ",
1202
D.a(F.repoUrl('attachlist',{page:wi.name}),
1203
"Attachments for page ["+wi.name+"]."),
1204
" ",
1205
D.a(F.repoUrl('attachadd',{
1206
page:wi.name,
1207
from: F.repoUrl('wikiedit',{name: wi.name})}),
1208
"Add attachments..." )
1209
)
1210
);
1211
wi.attachments.forEach(function(a){
1212
const wrap = D.div();
1213
D.append(f.eAttach, wrap);
1214
D.append(wrap,
1215
D.append(D.div(),
1216
"Attachment ",
1217
D.addClass(
1218
D.a(F.repoUrl('ainfo',{name:a.uuid}),
1219
F.hashDigits(a.uuid,true)),
1220
'monospace'),
1221
" ",
1222
a.filename,
1223
(a.isLatest ? " (latest)" : "")
1224
)
1225
);
1226
//D.append(wrap,D.append(D.div(), "URLs:"));
1227
const ul = D.ul();
1228
D.append(wrap, ul);
1229
[ // List download URL variants for each attachment:
1230
[
1231
"attachdownload?page=",
1232
encodeURIComponent(wi.name),
1233
"&file=",
1234
encodeURIComponent(a.filename)
1235
].join(''),
1236
"raw/"+a.src
1237
].forEach(function(url){
1238
const imgUrl = D.append(D.addClass(D.span(), 'monospace'), url);
1239
const urlCopy = D.button();
1240
const li = D.li(ul);
1241
D.append(li, urlCopy, imgUrl);
1242
F.copyButton(urlCopy, {copyFromElement: imgUrl});
1243
});
1244
});
1245
return this;
1246
};
1247
1248
/** Updates the in-tab title/edit status information */
1249
P.updateEditStatus = function f(){
1250
if(!f.eLinks){
1251
f.eName = P.e.editStatus.querySelector('span.name');
1252
f.eLinks = P.e.editStatus.querySelector('span.links');
1253
}
1254
const wi = this.winfo;
1255
D.clearElement(f.eName, f.eLinks);
1256
if(!wi){
1257
D.append(f.eName, '(no page loaded)');
1258
this.updateAttachmentsView();
1259
return this;
1260
}
1261
D.append(f.eName,getEditMarker(wi, false),wi.name);
1262
this.updateAttachmentsView();
1263
if(!wi.version) return this;
1264
D.append(
1265
f.eLinks,
1266
D.a(F.repoUrl('wiki',{name:wi.name}),"viewer"),
1267
D.a(F.repoUrl('whistory',{name:wi.name}),'history'),
1268
D.a(F.repoUrl('attachlist',{page:wi.name}),"attachments"),
1269
D.a(F.repoUrl('attachadd',{page:wi.name,from: F.repoUrl('wikiedit',{name: wi.name})}), "attach"),
1270
D.a(F.repoUrl('wikiedit',{name:wi.name}),"editor permalink")
1271
);
1272
return this;
1273
};
1274
1275
/**
1276
Update the page title and header based on the state of
1277
this.winfo. A no-op if this.winfo is not set. Returns this.
1278
*/
1279
P.updatePageTitle = function f(){
1280
if(!f.titleElement){
1281
f.titleElement = document.head.querySelector('title');
1282
}
1283
const wi = P.winfo, marker = getEditMarker(wi, true),
1284
title = wi ? wi.name : 'no page loaded';
1285
f.titleElement.innerText = 'Wiki Editor: ' + marker + title;
1286
this.updateEditStatus();
1287
return this;
1288
};
1289
1290
/**
1291
Change the save button depending on whether we have stuff to save
1292
or not.
1293
*/
1294
P.updateSaveButton = function(){
1295
/**
1296
// Currently disabled, per forum feedback and platform-level
1297
// event-handling compatibility, but might be revisited. We now
1298
// use an is-dirty flag instead to prevent saving when no change
1299
// event has fired for the current doc.
1300
if(!this.winfo || !this.getStashedWinfo(this.winfo)){
1301
D.disable(this.e.btnSave).innerText =
1302
"No changes to save";
1303
D.disable(this.e.btnSaveClose);
1304
}else{
1305
D.enable(this.e.btnSave).innerText = "Save";
1306
D.enable(this.e.btnSaveClose);
1307
}*/
1308
return this;
1309
};
1310
1311
/**
1312
Getter (if called with no args) or setter (if passed an arg) for
1313
the current file content.
1314
1315
The setter form sets the content, dispatches a
1316
'wiki-content-replaced' event, and returns this object.
1317
*/
1318
P.wikiContent = function f(){
1319
if(0===arguments.length){
1320
return f.get();
1321
}else{
1322
f.set(arguments[0] || '');
1323
this.dispatchEvent('wiki-content-replaced', this);
1324
return this;
1325
}
1326
};
1327
/* Default get/set impls for file content */
1328
P.wikiContent.get = function(){return P.e.taEditor.value};
1329
P.wikiContent.set = function(content){P.e.taEditor.value = content};
1330
1331
/**
1332
For use when installing a custom editor widget. Pass it the
1333
getter and setter callbacks to fetch resp. set the content of the
1334
custom widget. They will be triggered via
1335
P.wikiContent(). Returns this object.
1336
*/
1337
P.setContentMethods = function(getter, setter){
1338
this.wikiContent.get = getter;
1339
this.wikiContent.set = setter;
1340
return this;
1341
};
1342
1343
/**
1344
Alerts the editor app that a "change" has happened in the editor.
1345
When connecting 3rd-party editor widgets to this app, it is
1346
necessary to call this for any "change" events the widget emits.
1347
Whether or not "change" means that there were "really" edits is
1348
irrelevant, but this app will not allow saving unless it believes
1349
at least one "change" has been made (by being signaled through
1350
this method).
1351
1352
This function may perform an arbitrary amount of work, so it
1353
should not be called for every keypress within the editor
1354
widget. Calling it for "blur" events is generally sufficient, and
1355
calling it for each Enter keypress is generally reasonable but
1356
also computationally costly.
1357
*/
1358
P.notifyOfChange = function(){
1359
P._isDirty = true;
1360
P.stashContentChange();
1361
};
1362
1363
/**
1364
Removes the default editor widget (and any dependent elements)
1365
from the DOM, adds the given element in its place, removes this
1366
method from this object, and returns this object. This is not
1367
needed if the 3rd-party widget replaces or hides this app's
1368
editor widget (e.g. TinyMCE).
1369
*/
1370
P.replaceEditorElement = function(newEditor){
1371
P.e.taEditor.parentNode.insertBefore(newEditor, P.e.taEditor);
1372
P.e.taEditor.remove();
1373
P.e.selectFontSizeWrap.remove();
1374
delete this.replaceEditorElement;
1375
return P;
1376
};
1377
1378
/**
1379
Sets the current page's base.href to {g.zTop}/wiki.
1380
*/
1381
P.baseHrefForWiki = function f(){
1382
this.base.tag.href = this.base.wikiUrl;
1383
return this;
1384
};
1385
1386
/**
1387
Sets the document's base.href value to its page-load-time
1388
setting.
1389
*/
1390
P.baseHrefRestore = function(){
1391
this.base.tag.href = this.base.originalHref;
1392
};
1393
1394
1395
/**
1396
loadPage() loads the given wiki page and updates the relevant
1397
UI elements to reflect the loaded state. If passed no arguments
1398
then it re-uses the values from the currently-loaded page, reloading
1399
it (emitting an error message if no file is loaded).
1400
1401
Returns this object, noting that the load is async. After loading
1402
it triggers a 'wiki-page-loaded' event, passing it this.winfo.
1403
1404
If a locally-edited copy of the given file/rev is found, that
1405
copy is used instead of one fetched from the server, but it is
1406
still treated as a load event.
1407
1408
Alternate call forms:
1409
1410
- no arguments: re-loads from this.winfo.
1411
1412
- 1 non-string argument: assumed to be an winfo-style
1413
object. Must have at least the {name} property, but need not have
1414
other winfo state.
1415
*/
1416
P.loadPage = function(name){
1417
if(0===arguments.length){
1418
/* Reload from this.winfo */
1419
if(!affirmPageLoaded()) return this;
1420
name = this.winfo.name;
1421
}else if(1===arguments.length && 'string' !== typeof name){
1422
/* Assume winfo-like object */
1423
const arg = arguments[0];
1424
name = arg.name;
1425
}
1426
const onload = (r)=>{
1427
this.dispatchEvent('wiki-page-loaded', r);
1428
};
1429
const stashWinfo = this.getStashedWinfo({name: name});
1430
if(stashWinfo){ // fake a response from the stash...
1431
F.message("Fetched from the local-edit storage:", stashWinfo.name);
1432
onload({
1433
name: stashWinfo.name,
1434
mimetype: stashWinfo.mimetype,
1435
type: stashWinfo.type,
1436
version: stashWinfo.version,
1437
parent: stashWinfo.parent,
1438
isEmpty: !!stashWinfo.isEmpty,
1439
content: $stash.stashedContent(stashWinfo),
1440
attachments: stashWinfo.attachments
1441
});
1442
this._isDirty = true/*b/c loading normally clears that flag*/;
1443
return this;
1444
}
1445
F.message(
1446
"Loading content..."
1447
).fetch('wikiajax/fetch',{
1448
urlParams: {
1449
page: name
1450
},
1451
responseType: 'json',
1452
onload:(r)=>{
1453
F.message('Loaded page ['+r.name+'].');
1454
onload(r);
1455
}
1456
});
1457
return this;
1458
};
1459
1460
/**
1461
Fetches the page preview based on the contents and settings of
1462
this page's input fields, and updates the UI with the
1463
preview.
1464
1465
Returns this object, noting that the operation is async.
1466
*/
1467
P.preview = function f(switchToTab){
1468
if(!affirmPageLoaded()) return this;
1469
return this._postPreview(this.wikiContent(), function(c){
1470
P._previewTo(c);
1471
if(switchToTab) self.tabs.switchToTab(self.e.tabs.preview);
1472
});
1473
};
1474
1475
/**
1476
Callback for use with F.connectPagePreviewers(). Gets passed
1477
the preview content.
1478
*/
1479
P._previewTo = function(c){
1480
const target = this.e.previewTarget;
1481
D.clearElement(target);
1482
if('string'===typeof c) D.parseHtml(target,c);
1483
if(F.pikchr){
1484
F.pikchr.addSrcView(target.querySelectorAll('svg.pikchr'));
1485
}
1486
};
1487
1488
/**
1489
Callback for use with F.connectPagePreviewers()
1490
*/
1491
P._postPreview = function(content,callback){
1492
if(!affirmPageLoaded()) return this;
1493
if(!content){
1494
callback(content);
1495
return this;
1496
}
1497
const fd = new FormData();
1498
const mimetype = this.e.selectMimetype.value;
1499
fd.append('page', this.winfo.name);
1500
fd.append('mimetype',mimetype);
1501
fd.append('content',content || '');
1502
F.message(
1503
"Fetching preview..."
1504
).fetch('wikiajax/preview',{
1505
payload: fd,
1506
onload: (r,header)=>{
1507
callback(r);
1508
F.message('Updated preview.');
1509
P.previewNeedsUpdate = false;
1510
P.dispatchEvent('wiki-preview-updated',{
1511
mimetype: mimetype,
1512
element: P.e.previewTarget
1513
});
1514
},
1515
onerror: (e)=>{
1516
F.fetch.onerror(e);
1517
callback("Error fetching preview: "+e);
1518
}
1519
});
1520
return this;
1521
};
1522
1523
/**
1524
Fetches the content diff based on the contents and settings of
1525
this page's input fields, and updates the UI with the diff view.
1526
1527
Returns this object, noting that the operation is async.
1528
*/
1529
P.diff = function f(sbs){
1530
if(!affirmPageLoaded()) return this;
1531
const content = this.wikiContent(),
1532
self = this,
1533
target = this.e.diffTarget;
1534
const fd = new FormData();
1535
fd.append('page',this.winfo.name);
1536
fd.append('sbs', sbs ? 1 : 0);
1537
fd.append('content',content);
1538
if(this.e.selectDiffWS) fd.append('ws',this.e.selectDiffWS.value);
1539
F.message(
1540
"Fetching diff..."
1541
).fetch('wikiajax/diff',{
1542
payload: fd,
1543
onload: function(c){
1544
D.parseHtml(D.clearElement(target), [
1545
"<div>Diff <code>[",
1546
self.winfo.name,
1547
"]</code> &rarr; Local Edits</div>",
1548
c||'No changes.'
1549
].join(''));
1550
F.diff.setupDiffContextLoad();
1551
if(sbs) P.tweakSbsDiffs();
1552
F.message('Updated diff.');
1553
self.tabs.switchToTab(self.e.tabs.diff);
1554
}
1555
});
1556
return this;
1557
};
1558
1559
/**
1560
Saves the current wiki page and re-populates the editor
1561
with the saved state. If passed an argument, it is
1562
expected to be a function, which is called only if
1563
saving succeeds, after all other post-save processing.
1564
*/
1565
P.save = function callee(onSuccessCallback){
1566
if(!affirmPageLoaded()) return this;
1567
else if(!this._isDirty){
1568
F.error("There are no changes to save.");
1569
return this;
1570
}
1571
const content = this.wikiContent();
1572
const self = this;
1573
callee.onload = function(w){
1574
const oldWinfo = self.winfo;
1575
self.unstashContent(oldWinfo);
1576
self.dispatchEvent('wiki-page-loaded', w)/* will reset save buttons */;
1577
F.message("Saved page: ["+w.name+"].");
1578
if('function'===typeof onSuccessCallback){
1579
onSuccessCallback();
1580
}
1581
};
1582
const fd = new FormData(), w = P.winfo;
1583
fd.append('page',w.name);
1584
fd.append('mimetype', w.mimetype);
1585
fd.append('isnew', w.version ? 0 : 1);
1586
fd.append('content', P.wikiContent());
1587
F.message(
1588
"Saving page..."
1589
).fetch('wikiajax/save',{
1590
payload: fd,
1591
responseType: 'json',
1592
onload: callee.onload
1593
});
1594
return this;
1595
};
1596
1597
/**
1598
Updates P.winfo for certain state and stashes P.winfo, with the
1599
current content fetched via P.wikiContent().
1600
1601
If passed truthy AND the stash already has stashed content for
1602
the current page, only the stashed winfo record is updated, else
1603
both the winfo and content are updated.
1604
*/
1605
P.stashContentChange = function(onlyWinfo){
1606
if(affirmPageLoaded(true)){
1607
const wi = this.winfo;
1608
wi.mimetype = P.e.selectMimetype.value;
1609
if(onlyWinfo && $stash.hasStashedContent(wi)){
1610
$stash.updateWinfo(wi);
1611
}else{
1612
$stash.updateWinfo(wi, P.wikiContent());
1613
}
1614
F.message("Stashed changes to page ["+wi.name+"].");
1615
P.updatePageTitle();
1616
$stash.prune();
1617
this.previewNeedsUpdate = true;
1618
}
1619
return this;
1620
};
1621
1622
/**
1623
Removes any stashed state for the current P.winfo (if set) from
1624
F.storage. Returns this.
1625
*/
1626
P.unstashContent = function(){
1627
const winfo = arguments[0] || this.winfo;
1628
if(winfo){
1629
this.previewNeedsUpdate = true;
1630
$stash.unstash(winfo);
1631
//console.debug("Unstashed",winfo);
1632
F.message("Unstashed page ["+winfo.name+"].");
1633
}
1634
return this;
1635
};
1636
1637
/**
1638
Clears all stashed file state from F.storage. Returns this.
1639
*/
1640
P.clearStash = function(){
1641
$stash.clear();
1642
return this;
1643
};
1644
1645
/**
1646
If stashed content for P.winfo exists, it is returned, else
1647
undefined is returned.
1648
*/
1649
P.contentFromStash = function(){
1650
return affirmPageLoaded(true) ? $stash.stashedContent(this.winfo) : undefined;
1651
};
1652
1653
/**
1654
If a stashed version of the given winfo object exists (same
1655
filename/checkin values), return it, else return undefined.
1656
*/
1657
P.getStashedWinfo = function(winfo){
1658
return $stash.getWinfo(winfo);
1659
};
1660
})(window.fossil);
1661

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button