Fossil SCM

Milestone: the first inline edit in threaded forum view. There's lots of refinement to do, though, and it currently can only save the top-most post due to mishandling of the title.

stephan 2026-06-07 19:47 UTC forum-editor-2026
Commit 7d7b74dd88f3b08d067c2936015817d1658325c0e0a0f7ae1b4633eea7439187
+1 -1
--- src/default.css
+++ src/default.css
@@ -1693,11 +1693,11 @@
16931693
-0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
16941694
0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
16951695
0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
16961696
L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
16971697
9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698
-style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1698
+style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
16991699
background-repeat: no-repeat;
17001700
background-position: center;
17011701
/* When not using a background image, this additional style works
17021702
reasonably well along with a ::before content of "?": */
17031703
/*border-width: 1px;
17041704
--- src/default.css
+++ src/default.css
@@ -1693,11 +1693,11 @@
1693 -0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
1694 0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
1695 0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
1696 L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
1697 9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698 style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1699 background-repeat: no-repeat;
1700 background-position: center;
1701 /* When not using a background image, this additional style works
1702 reasonably well along with a ::before content of "?": */
1703 /*border-width: 1px;
1704
--- src/default.css
+++ src/default.css
@@ -1693,11 +1693,11 @@
1693 -0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
1694 0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
1695 0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
1696 L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
1697 9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698 style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1699 background-repeat: no-repeat;
1700 background-position: center;
1701 /* When not using a background image, this additional style works
1702 reasonably well along with a ::before content of "?": */
1703 /*border-width: 1px;
1704
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -44,10 +44,18 @@
4444
opt.ondiscard[=function]: if set, a Discard button is added
4545
which, when activated, clears the current draft and removes
4646
this object's widget from the DOM. After that, opt.ondiscard()
4747
is called and passed no arguments.
4848
49
+ opt.onsubmit[=function]: if set, this function is called
50
+ immediately after the post has been successfully saved,
51
+ and passed this object.
52
+
53
+ opt.hiddenFields: an optional list of input elements to
54
+ incorporate into the form for requests which request the
55
+ preview or save the post.
56
+
4957
TODO:
5058
5159
opt.inReplyTo=uuid: if this is a new response to a post, this
5260
is the full forum post uuid of the being-replied-to post.
5361
@@ -71,11 +79,11 @@
7179
button: F.nu()
7280
});
7381
const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
7482
D.clearElement(wrapper);
7583
76
- if( !opt.inReplyTo ){
84
+ if( !opt.inReplyTo && !opt.hideTitle ){
7785
/* Title... */
7886
e.titleBar = D.addClass(D.div(),'titlebar');
7987
e.title = D.attr(
8088
D.addClass(D.input('text'), 'title'),
8189
'placeholder',
@@ -205,11 +213,11 @@
205213
e.tabEdit.append(e.editor);
206214
e.tabEdit.dataset.tabLabel = 'Edit';
207215
this.#tabs.addTab( e.tabEdit );
208216
this.#tabs.switchToTab( e.tabEdit );
209217
if( this.#draft ){
210
- this.editorContent = opt.edit?.W || this.#draft.content || '';
218
+ this.editorContent = this.#draft.content || opt.edit?.W || '';
211219
e.editor.addEventListener(
212220
'blur', ()=>{
213221
this.#draft.content = this.editorContent;
214222
this.#storeDraft();
215223
}
@@ -217,10 +225,11 @@
217225
}else if( opt.edit?.W ){
218226
this.editorContent = opt.artifact.W;
219227
}
220228
e.preview = D.addClass(D.div(), 'preview');
221229
e.preview.dataset.tabLabel = 'Preview';
230
+ this.#toDisable.push(e.button.preview);
222231
this.#tabs.addTab( e.preview );
223232
}
224233
225234
if( F.user.enableDebug ){
226235
e.debug = D.addClass(D.div(), 'debug');
@@ -282,11 +291,10 @@
282291
e.buttons.append(e.button.preview, e.button.submit);
283292
if( e.button.discard ){
284293
e.buttons.append(e.button.discard);
285294
this.#toDisable.push(e.button.discard);
286295
}
287
- this.#toDisable.push(e.button.preview);
288296
289297
e.help = D.attr(D.div(), 'id', idPrefix+'-help');
290298
e.help.$needsInit = true;
291299
e.help.dataset.tabLabel = 'Help';
292300
this.#tabs.addTab(e.help);
@@ -345,12 +353,11 @@
345353
return false;
346354
}
347355
}, true);
348356
}/*shift-enter preview bits*/
349357
350
- {
351
- let visible = true;
358
+ if(0){ /* Needs to be optional */
352359
const elemsToToggle = document.body.querySelectorAll(
353360
':scope > header, :scope > nav'
354361
);
355362
e.button.toggleHeader =
356363
D.button('Toggle header', e=>{
@@ -362,13 +369,14 @@
362369
}
363370
364371
}/*constructor*/
365372
366373
discard(){
367
- this.#clearDraft();
368374
const e = this.#e.widget;
369375
if( e.parentNode ){
376
+ console.debug("FPE discarding", this);
377
+ this.#clearDraft();
370378
e.remove();
371379
if( this.#opt.ondiscard instanceof Function ){
372380
this.#opt.ondiscard();
373381
}
374382
}
@@ -423,11 +431,11 @@
423431
collecting, e.g., the CSRF token and an initial page title.
424432
*/
425433
addHiddenFields(list){
426434
this.#extraFields ??= [];
427435
for( const f of list ){
428
- if( 'title'===f.name ){
436
+ if( 'title'===f.name && this.#e.title ){
429437
if( f.value && this.#opt.isNewThread && !this.#e.title.value ){
430438
this.#e.title.value = f.value;
431439
}
432440
}else{
433441
this.#extraFields.push(f);
@@ -438,11 +446,11 @@
438446
get mimetype(){
439447
return this.#e.mimetype.select.value;
440448
}
441449
442450
get title(){
443
- return this.#e.title.value;
451
+ return this.#e.title?.value;
444452
}
445453
446454
#initHelpTab(){
447455
const eh = this.#e.help;
448456
const list = D.ul();
@@ -474,12 +482,16 @@
474482
#newFormData(addThisContent){
475483
const fd = new FormData;
476484
for(const f of this.#extraFields){
477485
fd.append(f.name, f.value);
478486
}
487
+ if( this.#e.title ){
488
+ fd.append('title', this.title.trim());
489
+ }else if( this.#opt.edit?.H ){
490
+ fd.append('title', this.#opt.edit.H);
491
+ }
479492
fd.append('mimetype', this.mimetype);
480
- fd.append('title', this.title.trim());
481493
fd.append('content', addThisContent || this.editorContent.trim());
482494
if( this.#e.captcha ){
483495
fd.append('captcha', this.#e.captcha.value);
484496
}
485497
return fd;
@@ -555,12 +567,12 @@
555567
#validate(tgt){
556568
if( this.#e.captcha && 8!==this.#e.captcha.value.length ){
557569
this.reportError("Enter the captcha value.");
558570
return;
559571
}
560
- if( this.#opt.isNewThread ){
561
- let v = this.#e.title.value.trim();
572
+ if( this.#e.title ){
573
+ const v = this.#e.title.value.trim();
562574
if( !v ){
563575
this.reportError("A non-empty title is required.");
564576
return;
565577
}
566578
}
@@ -616,10 +628,13 @@
616628
this.reportError(j.message);
617629
return;
618630
}
619631
if( 1 ){
620632
this.#clearDraft();
633
+ if( this.#opt.onsubmit instanceof Function ){
634
+ this.#opt.onsubmit(this);
635
+ }
621636
window.location = F.repoUrl('forumpost/'+j.uuid);
622637
}else{
623638
this.reportError(
624639
"Saving worked but we're ignoring it and staying here."
625640
);
@@ -629,13 +644,37 @@
629644
.finally(()=>this.#isWaiting = false);
630645
}
631646
632647
#storeDraft(){
633648
if( this.#draft ){
649
+ this.#draft.mtime = Date.now();
634650
F.storage.setJSON(this.#opt.draftKey, this.#draft);
635651
}
636652
}
653
+
654
+ /**
655
+ Looks for editing draft keys matching either a fixes key or a
656
+ regex, and removes each matching one which is older than some
657
+ fixed "best-before" date.
658
+ */
659
+ static purgeOldDrafts(key){
660
+ const age = (3600 * 24 * 10/*days*/) * 1000/*ms*/;
661
+ const now = Date().now();
662
+ if( key instanceof RegExp ){
663
+ for(const k of F.storage.keys().filter(v=>key.test(v))){
664
+ const o = F.getJSON(k);
665
+ if( o && (o.mtime+age < now)){
666
+ F.storage.remove(k);
667
+ }
668
+ }
669
+ }else{
670
+ const o = F.getJSON(key);
671
+ if( o && (o.mtime+age < now)){
672
+ F.storage.remove(key);
673
+ }
674
+ }
675
+ }
637676
638677
async #fetchPost(){
639678
/*
640679
TODO: when editing an existing post, fetch the raw body of the
641680
post and populate this.e.
@@ -819,33 +858,36 @@
819858
});
820859
});
821860
});
822861
}
823862
824
- const userIsIndividual = ['anonymous','guest'].indexOf(F.user.name)<0;
863
+ F.user.isIndividual = ['anonymous','guest'].indexOf(F.user.name)<0;
864
+
825865
const eForumNew = document.body.classList.contains('cpage-forumnew')
826866
? document.querySelector('#forumnew-placeholder')
827867
: null;
828868
if( eForumNew ){
829869
/* /forumnew */
830
- const fpe = new fossil.ForumPostEditor({
831
- draftKey: 'forumnew',
870
+ const fpe = new F.ForumPostEditor({
871
+ draftKey: 'draft-forumnew',
832872
hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'),
833873
captcha: eForumNew.querySelector('.captcha-for-js'),
834874
ondiscard: ()=>{
835875
window.location = F.repoUrl('forum');
836876
}
837
- //mimetype: 'text/plain'
838877
});
839878
eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
840879
eForumNew.remove();
841880
fossil.page.fpe = fpe /* for testing via the console */;
842881
}/*eForumNew*/
843
- else if( userIsIndividual
882
+ else if( F.user.isIndividual
844883
&& (document.body.classList.contains('cpage-forumpost')
845884
|| document.body.classList.contains('cpage-forumthread'))){
846
- /* /forumpost and /forumthread */
885
+ /* /forumpost and /forumthread. Take over the Edit/Reply buttons
886
+ to use a ForumPostEditor. Because of complications involving
887
+ fetching a captcha, we'll leave the buttons as-is for
888
+ non-logged-in users. */
847889
848890
const fetchPost = async (fpid)=>{
849891
return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid))
850892
.then(r=>r.json())
851893
.then(j=>{
@@ -873,11 +915,11 @@
873915
eButton.innerText = eButton.dataset.originalLabel;
874916
delete eButton.dataset.originalLabel;
875917
}
876918
};
877919
878
- const replyClicked = (ePost, eBtnReply)=>{
920
+ const replyClicked = (form, ePost, eBtnReply)=>{
879921
const fpid = setupEditReplyElement(ePost, eBtnReply);
880922
eBtnReply.innerText = "Replying...";
881923
F.toast.error("Reply is TODO. fpid="+fpid);
882924
/*
883925
TODOs include:
@@ -892,14 +934,14 @@
892934
a Cancel button.
893935
894936
- When cancelled or submitted, restore the reply button and
895937
ePost position.
896938
*/
897
- if( 0 ) restoreEditReplyElement(ePost, eBtnReply);
939
+ restoreEditReplyElement(ePost, eBtnReply);
898940
}/*replyClicked()*/;
899941
900
- const editClicked = (ePost, eBtnEdit)=>{
942
+ const editClicked = (form, ePost, eBtnEdit)=>{
901943
const fpid = setupEditReplyElement(ePost, eBtnEdit);
902944
eBtnEdit.innerText = "Editing...";
903945
F.toast.error("Edit is TODO. fpid="+fpid);
904946
/*
905947
TODOs include:
@@ -913,13 +955,31 @@
913955
914956
- When cancelled or submitted, restore the edit button and
915957
ePost position.
916958
*/
917959
fetchPost(fpid)
918
- .then(j=>{
919
- console.debug("Got post... now what?", j);
920
- if( 0 ) restoreEditReplyElement(ePost, eBtnEdit);
960
+ .then(artifact=>{
961
+ const ondone = (fpe)=>{
962
+ restoreEditReplyElement(ePost, eBtnEdit);
963
+ console.debug("ondiscard/onsubmit", fpe);
964
+ if( fpe/*onsubmit*/ ){
965
+ if( fpe.widget.parentNode ){
966
+ fpe.widget.remove();
967
+ }
968
+ }
969
+ };
970
+ const fpe = new F.ForumPostEditor({
971
+ hiddenFields: form.querySelectorAll('input[type=hidden]'),
972
+ ondiscard: ondone,
973
+ onsubmit: ondone,
974
+ draftKey: 'draft-forumedit-'+fpid.substr(0,12),
975
+ hideTitle: true/*fixme: only show if this is the root post*/,
976
+ edit: artifact
977
+ });
978
+ const w = fpe.widget;
979
+ w.style.borderTop = '2px dotted';
980
+ ePost.append(w);
921981
});
922982
}/*editClicked()*/;
923983
924984
document.body.querySelectorAll(
925985
'.forumpost-single-controls > form'
@@ -931,23 +991,27 @@
931991
return;
932992
}
933993
const btnReply = form.querySelector('input[type=submit][name=reply]');
934994
if( btnReply ){
935995
//console.debug("hacking Reply button", btnReply);
936
- const b = D.button("Reply", ()=>replyClicked(eThePost, b));
996
+ const b = D.button("Reply", ()=>replyClicked(form, eThePost, b));
937997
b.type = 'button'/*keep container form from submitting*/;
938998
btnReply.parentElement.insertBefore(b, btnReply);
939999
btnReply.remove();
9401000
}
9411001
const btnEdit = form.querySelector('input[type=submit][name=edit]');
9421002
if( btnEdit ){
9431003
//console.debug("hacking Edit button", btnEdit);
944
- const b = D.button("Edit", ()=>editClicked(eThePost, b));
1004
+ const b = D.button("Edit", ()=>editClicked(form, eThePost, b));
9451005
b.type = 'button';
9461006
btnEdit.parentElement.insertBefore(b, btnEdit);
9471007
btnEdit.remove();
9481008
}
9491009
})/*for-each form*/;
9501010
}/* /forumpost and /forumthread */
9511011
1012
+ if( 0 ){
1013
+ /* TODO every now and then, not every request... */
1014
+ F.ForumPostEditor.purgeOldDrafts(/^draft-forum.*/);
1015
+ }
9521016
})/*F.onPageLoad callback*/;
9531017
})(window.fossil);
9541018
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -44,10 +44,18 @@
44 opt.ondiscard[=function]: if set, a Discard button is added
45 which, when activated, clears the current draft and removes
46 this object's widget from the DOM. After that, opt.ondiscard()
47 is called and passed no arguments.
48
 
 
 
 
 
 
 
 
49 TODO:
50
51 opt.inReplyTo=uuid: if this is a new response to a post, this
52 is the full forum post uuid of the being-replied-to post.
53
@@ -71,11 +79,11 @@
71 button: F.nu()
72 });
73 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
74 D.clearElement(wrapper);
75
76 if( !opt.inReplyTo ){
77 /* Title... */
78 e.titleBar = D.addClass(D.div(),'titlebar');
79 e.title = D.attr(
80 D.addClass(D.input('text'), 'title'),
81 'placeholder',
@@ -205,11 +213,11 @@
205 e.tabEdit.append(e.editor);
206 e.tabEdit.dataset.tabLabel = 'Edit';
207 this.#tabs.addTab( e.tabEdit );
208 this.#tabs.switchToTab( e.tabEdit );
209 if( this.#draft ){
210 this.editorContent = opt.edit?.W || this.#draft.content || '';
211 e.editor.addEventListener(
212 'blur', ()=>{
213 this.#draft.content = this.editorContent;
214 this.#storeDraft();
215 }
@@ -217,10 +225,11 @@
217 }else if( opt.edit?.W ){
218 this.editorContent = opt.artifact.W;
219 }
220 e.preview = D.addClass(D.div(), 'preview');
221 e.preview.dataset.tabLabel = 'Preview';
 
222 this.#tabs.addTab( e.preview );
223 }
224
225 if( F.user.enableDebug ){
226 e.debug = D.addClass(D.div(), 'debug');
@@ -282,11 +291,10 @@
282 e.buttons.append(e.button.preview, e.button.submit);
283 if( e.button.discard ){
284 e.buttons.append(e.button.discard);
285 this.#toDisable.push(e.button.discard);
286 }
287 this.#toDisable.push(e.button.preview);
288
289 e.help = D.attr(D.div(), 'id', idPrefix+'-help');
290 e.help.$needsInit = true;
291 e.help.dataset.tabLabel = 'Help';
292 this.#tabs.addTab(e.help);
@@ -345,12 +353,11 @@
345 return false;
346 }
347 }, true);
348 }/*shift-enter preview bits*/
349
350 {
351 let visible = true;
352 const elemsToToggle = document.body.querySelectorAll(
353 ':scope > header, :scope > nav'
354 );
355 e.button.toggleHeader =
356 D.button('Toggle header', e=>{
@@ -362,13 +369,14 @@
362 }
363
364 }/*constructor*/
365
366 discard(){
367 this.#clearDraft();
368 const e = this.#e.widget;
369 if( e.parentNode ){
 
 
370 e.remove();
371 if( this.#opt.ondiscard instanceof Function ){
372 this.#opt.ondiscard();
373 }
374 }
@@ -423,11 +431,11 @@
423 collecting, e.g., the CSRF token and an initial page title.
424 */
425 addHiddenFields(list){
426 this.#extraFields ??= [];
427 for( const f of list ){
428 if( 'title'===f.name ){
429 if( f.value && this.#opt.isNewThread && !this.#e.title.value ){
430 this.#e.title.value = f.value;
431 }
432 }else{
433 this.#extraFields.push(f);
@@ -438,11 +446,11 @@
438 get mimetype(){
439 return this.#e.mimetype.select.value;
440 }
441
442 get title(){
443 return this.#e.title.value;
444 }
445
446 #initHelpTab(){
447 const eh = this.#e.help;
448 const list = D.ul();
@@ -474,12 +482,16 @@
474 #newFormData(addThisContent){
475 const fd = new FormData;
476 for(const f of this.#extraFields){
477 fd.append(f.name, f.value);
478 }
 
 
 
 
 
479 fd.append('mimetype', this.mimetype);
480 fd.append('title', this.title.trim());
481 fd.append('content', addThisContent || this.editorContent.trim());
482 if( this.#e.captcha ){
483 fd.append('captcha', this.#e.captcha.value);
484 }
485 return fd;
@@ -555,12 +567,12 @@
555 #validate(tgt){
556 if( this.#e.captcha && 8!==this.#e.captcha.value.length ){
557 this.reportError("Enter the captcha value.");
558 return;
559 }
560 if( this.#opt.isNewThread ){
561 let v = this.#e.title.value.trim();
562 if( !v ){
563 this.reportError("A non-empty title is required.");
564 return;
565 }
566 }
@@ -616,10 +628,13 @@
616 this.reportError(j.message);
617 return;
618 }
619 if( 1 ){
620 this.#clearDraft();
 
 
 
621 window.location = F.repoUrl('forumpost/'+j.uuid);
622 }else{
623 this.reportError(
624 "Saving worked but we're ignoring it and staying here."
625 );
@@ -629,13 +644,37 @@
629 .finally(()=>this.#isWaiting = false);
630 }
631
632 #storeDraft(){
633 if( this.#draft ){
 
634 F.storage.setJSON(this.#opt.draftKey, this.#draft);
635 }
636 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
638 async #fetchPost(){
639 /*
640 TODO: when editing an existing post, fetch the raw body of the
641 post and populate this.e.
@@ -819,33 +858,36 @@
819 });
820 });
821 });
822 }
823
824 const userIsIndividual = ['anonymous','guest'].indexOf(F.user.name)<0;
 
825 const eForumNew = document.body.classList.contains('cpage-forumnew')
826 ? document.querySelector('#forumnew-placeholder')
827 : null;
828 if( eForumNew ){
829 /* /forumnew */
830 const fpe = new fossil.ForumPostEditor({
831 draftKey: 'forumnew',
832 hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'),
833 captcha: eForumNew.querySelector('.captcha-for-js'),
834 ondiscard: ()=>{
835 window.location = F.repoUrl('forum');
836 }
837 //mimetype: 'text/plain'
838 });
839 eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
840 eForumNew.remove();
841 fossil.page.fpe = fpe /* for testing via the console */;
842 }/*eForumNew*/
843 else if( userIsIndividual
844 && (document.body.classList.contains('cpage-forumpost')
845 || document.body.classList.contains('cpage-forumthread'))){
846 /* /forumpost and /forumthread */
 
 
 
847
848 const fetchPost = async (fpid)=>{
849 return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid))
850 .then(r=>r.json())
851 .then(j=>{
@@ -873,11 +915,11 @@
873 eButton.innerText = eButton.dataset.originalLabel;
874 delete eButton.dataset.originalLabel;
875 }
876 };
877
878 const replyClicked = (ePost, eBtnReply)=>{
879 const fpid = setupEditReplyElement(ePost, eBtnReply);
880 eBtnReply.innerText = "Replying...";
881 F.toast.error("Reply is TODO. fpid="+fpid);
882 /*
883 TODOs include:
@@ -892,14 +934,14 @@
892 a Cancel button.
893
894 - When cancelled or submitted, restore the reply button and
895 ePost position.
896 */
897 if( 0 ) restoreEditReplyElement(ePost, eBtnReply);
898 }/*replyClicked()*/;
899
900 const editClicked = (ePost, eBtnEdit)=>{
901 const fpid = setupEditReplyElement(ePost, eBtnEdit);
902 eBtnEdit.innerText = "Editing...";
903 F.toast.error("Edit is TODO. fpid="+fpid);
904 /*
905 TODOs include:
@@ -913,13 +955,31 @@
913
914 - When cancelled or submitted, restore the edit button and
915 ePost position.
916 */
917 fetchPost(fpid)
918 .then(j=>{
919 console.debug("Got post... now what?", j);
920 if( 0 ) restoreEditReplyElement(ePost, eBtnEdit);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921 });
922 }/*editClicked()*/;
923
924 document.body.querySelectorAll(
925 '.forumpost-single-controls > form'
@@ -931,23 +991,27 @@
931 return;
932 }
933 const btnReply = form.querySelector('input[type=submit][name=reply]');
934 if( btnReply ){
935 //console.debug("hacking Reply button", btnReply);
936 const b = D.button("Reply", ()=>replyClicked(eThePost, b));
937 b.type = 'button'/*keep container form from submitting*/;
938 btnReply.parentElement.insertBefore(b, btnReply);
939 btnReply.remove();
940 }
941 const btnEdit = form.querySelector('input[type=submit][name=edit]');
942 if( btnEdit ){
943 //console.debug("hacking Edit button", btnEdit);
944 const b = D.button("Edit", ()=>editClicked(eThePost, b));
945 b.type = 'button';
946 btnEdit.parentElement.insertBefore(b, btnEdit);
947 btnEdit.remove();
948 }
949 })/*for-each form*/;
950 }/* /forumpost and /forumthread */
951
 
 
 
 
952 })/*F.onPageLoad callback*/;
953 })(window.fossil);
954
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -44,10 +44,18 @@
44 opt.ondiscard[=function]: if set, a Discard button is added
45 which, when activated, clears the current draft and removes
46 this object's widget from the DOM. After that, opt.ondiscard()
47 is called and passed no arguments.
48
49 opt.onsubmit[=function]: if set, this function is called
50 immediately after the post has been successfully saved,
51 and passed this object.
52
53 opt.hiddenFields: an optional list of input elements to
54 incorporate into the form for requests which request the
55 preview or save the post.
56
57 TODO:
58
59 opt.inReplyTo=uuid: if this is a new response to a post, this
60 is the full forum post uuid of the being-replied-to post.
61
@@ -71,11 +79,11 @@
79 button: F.nu()
80 });
81 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
82 D.clearElement(wrapper);
83
84 if( !opt.inReplyTo && !opt.hideTitle ){
85 /* Title... */
86 e.titleBar = D.addClass(D.div(),'titlebar');
87 e.title = D.attr(
88 D.addClass(D.input('text'), 'title'),
89 'placeholder',
@@ -205,11 +213,11 @@
213 e.tabEdit.append(e.editor);
214 e.tabEdit.dataset.tabLabel = 'Edit';
215 this.#tabs.addTab( e.tabEdit );
216 this.#tabs.switchToTab( e.tabEdit );
217 if( this.#draft ){
218 this.editorContent = this.#draft.content || opt.edit?.W || '';
219 e.editor.addEventListener(
220 'blur', ()=>{
221 this.#draft.content = this.editorContent;
222 this.#storeDraft();
223 }
@@ -217,10 +225,11 @@
225 }else if( opt.edit?.W ){
226 this.editorContent = opt.artifact.W;
227 }
228 e.preview = D.addClass(D.div(), 'preview');
229 e.preview.dataset.tabLabel = 'Preview';
230 this.#toDisable.push(e.button.preview);
231 this.#tabs.addTab( e.preview );
232 }
233
234 if( F.user.enableDebug ){
235 e.debug = D.addClass(D.div(), 'debug');
@@ -282,11 +291,10 @@
291 e.buttons.append(e.button.preview, e.button.submit);
292 if( e.button.discard ){
293 e.buttons.append(e.button.discard);
294 this.#toDisable.push(e.button.discard);
295 }
 
296
297 e.help = D.attr(D.div(), 'id', idPrefix+'-help');
298 e.help.$needsInit = true;
299 e.help.dataset.tabLabel = 'Help';
300 this.#tabs.addTab(e.help);
@@ -345,12 +353,11 @@
353 return false;
354 }
355 }, true);
356 }/*shift-enter preview bits*/
357
358 if(0){ /* Needs to be optional */
 
359 const elemsToToggle = document.body.querySelectorAll(
360 ':scope > header, :scope > nav'
361 );
362 e.button.toggleHeader =
363 D.button('Toggle header', e=>{
@@ -362,13 +369,14 @@
369 }
370
371 }/*constructor*/
372
373 discard(){
 
374 const e = this.#e.widget;
375 if( e.parentNode ){
376 console.debug("FPE discarding", this);
377 this.#clearDraft();
378 e.remove();
379 if( this.#opt.ondiscard instanceof Function ){
380 this.#opt.ondiscard();
381 }
382 }
@@ -423,11 +431,11 @@
431 collecting, e.g., the CSRF token and an initial page title.
432 */
433 addHiddenFields(list){
434 this.#extraFields ??= [];
435 for( const f of list ){
436 if( 'title'===f.name && this.#e.title ){
437 if( f.value && this.#opt.isNewThread && !this.#e.title.value ){
438 this.#e.title.value = f.value;
439 }
440 }else{
441 this.#extraFields.push(f);
@@ -438,11 +446,11 @@
446 get mimetype(){
447 return this.#e.mimetype.select.value;
448 }
449
450 get title(){
451 return this.#e.title?.value;
452 }
453
454 #initHelpTab(){
455 const eh = this.#e.help;
456 const list = D.ul();
@@ -474,12 +482,16 @@
482 #newFormData(addThisContent){
483 const fd = new FormData;
484 for(const f of this.#extraFields){
485 fd.append(f.name, f.value);
486 }
487 if( this.#e.title ){
488 fd.append('title', this.title.trim());
489 }else if( this.#opt.edit?.H ){
490 fd.append('title', this.#opt.edit.H);
491 }
492 fd.append('mimetype', this.mimetype);
 
493 fd.append('content', addThisContent || this.editorContent.trim());
494 if( this.#e.captcha ){
495 fd.append('captcha', this.#e.captcha.value);
496 }
497 return fd;
@@ -555,12 +567,12 @@
567 #validate(tgt){
568 if( this.#e.captcha && 8!==this.#e.captcha.value.length ){
569 this.reportError("Enter the captcha value.");
570 return;
571 }
572 if( this.#e.title ){
573 const v = this.#e.title.value.trim();
574 if( !v ){
575 this.reportError("A non-empty title is required.");
576 return;
577 }
578 }
@@ -616,10 +628,13 @@
628 this.reportError(j.message);
629 return;
630 }
631 if( 1 ){
632 this.#clearDraft();
633 if( this.#opt.onsubmit instanceof Function ){
634 this.#opt.onsubmit(this);
635 }
636 window.location = F.repoUrl('forumpost/'+j.uuid);
637 }else{
638 this.reportError(
639 "Saving worked but we're ignoring it and staying here."
640 );
@@ -629,13 +644,37 @@
644 .finally(()=>this.#isWaiting = false);
645 }
646
647 #storeDraft(){
648 if( this.#draft ){
649 this.#draft.mtime = Date.now();
650 F.storage.setJSON(this.#opt.draftKey, this.#draft);
651 }
652 }
653
654 /**
655 Looks for editing draft keys matching either a fixes key or a
656 regex, and removes each matching one which is older than some
657 fixed "best-before" date.
658 */
659 static purgeOldDrafts(key){
660 const age = (3600 * 24 * 10/*days*/) * 1000/*ms*/;
661 const now = Date().now();
662 if( key instanceof RegExp ){
663 for(const k of F.storage.keys().filter(v=>key.test(v))){
664 const o = F.getJSON(k);
665 if( o && (o.mtime+age < now)){
666 F.storage.remove(k);
667 }
668 }
669 }else{
670 const o = F.getJSON(key);
671 if( o && (o.mtime+age < now)){
672 F.storage.remove(key);
673 }
674 }
675 }
676
677 async #fetchPost(){
678 /*
679 TODO: when editing an existing post, fetch the raw body of the
680 post and populate this.e.
@@ -819,33 +858,36 @@
858 });
859 });
860 });
861 }
862
863 F.user.isIndividual = ['anonymous','guest'].indexOf(F.user.name)<0;
864
865 const eForumNew = document.body.classList.contains('cpage-forumnew')
866 ? document.querySelector('#forumnew-placeholder')
867 : null;
868 if( eForumNew ){
869 /* /forumnew */
870 const fpe = new F.ForumPostEditor({
871 draftKey: 'draft-forumnew',
872 hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'),
873 captcha: eForumNew.querySelector('.captcha-for-js'),
874 ondiscard: ()=>{
875 window.location = F.repoUrl('forum');
876 }
 
877 });
878 eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
879 eForumNew.remove();
880 fossil.page.fpe = fpe /* for testing via the console */;
881 }/*eForumNew*/
882 else if( F.user.isIndividual
883 && (document.body.classList.contains('cpage-forumpost')
884 || document.body.classList.contains('cpage-forumthread'))){
885 /* /forumpost and /forumthread. Take over the Edit/Reply buttons
886 to use a ForumPostEditor. Because of complications involving
887 fetching a captcha, we'll leave the buttons as-is for
888 non-logged-in users. */
889
890 const fetchPost = async (fpid)=>{
891 return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid))
892 .then(r=>r.json())
893 .then(j=>{
@@ -873,11 +915,11 @@
915 eButton.innerText = eButton.dataset.originalLabel;
916 delete eButton.dataset.originalLabel;
917 }
918 };
919
920 const replyClicked = (form, ePost, eBtnReply)=>{
921 const fpid = setupEditReplyElement(ePost, eBtnReply);
922 eBtnReply.innerText = "Replying...";
923 F.toast.error("Reply is TODO. fpid="+fpid);
924 /*
925 TODOs include:
@@ -892,14 +934,14 @@
934 a Cancel button.
935
936 - When cancelled or submitted, restore the reply button and
937 ePost position.
938 */
939 restoreEditReplyElement(ePost, eBtnReply);
940 }/*replyClicked()*/;
941
942 const editClicked = (form, ePost, eBtnEdit)=>{
943 const fpid = setupEditReplyElement(ePost, eBtnEdit);
944 eBtnEdit.innerText = "Editing...";
945 F.toast.error("Edit is TODO. fpid="+fpid);
946 /*
947 TODOs include:
@@ -913,13 +955,31 @@
955
956 - When cancelled or submitted, restore the edit button and
957 ePost position.
958 */
959 fetchPost(fpid)
960 .then(artifact=>{
961 const ondone = (fpe)=>{
962 restoreEditReplyElement(ePost, eBtnEdit);
963 console.debug("ondiscard/onsubmit", fpe);
964 if( fpe/*onsubmit*/ ){
965 if( fpe.widget.parentNode ){
966 fpe.widget.remove();
967 }
968 }
969 };
970 const fpe = new F.ForumPostEditor({
971 hiddenFields: form.querySelectorAll('input[type=hidden]'),
972 ondiscard: ondone,
973 onsubmit: ondone,
974 draftKey: 'draft-forumedit-'+fpid.substr(0,12),
975 hideTitle: true/*fixme: only show if this is the root post*/,
976 edit: artifact
977 });
978 const w = fpe.widget;
979 w.style.borderTop = '2px dotted';
980 ePost.append(w);
981 });
982 }/*editClicked()*/;
983
984 document.body.querySelectorAll(
985 '.forumpost-single-controls > form'
@@ -931,23 +991,27 @@
991 return;
992 }
993 const btnReply = form.querySelector('input[type=submit][name=reply]');
994 if( btnReply ){
995 //console.debug("hacking Reply button", btnReply);
996 const b = D.button("Reply", ()=>replyClicked(form, eThePost, b));
997 b.type = 'button'/*keep container form from submitting*/;
998 btnReply.parentElement.insertBefore(b, btnReply);
999 btnReply.remove();
1000 }
1001 const btnEdit = form.querySelector('input[type=submit][name=edit]');
1002 if( btnEdit ){
1003 //console.debug("hacking Edit button", btnEdit);
1004 const b = D.button("Edit", ()=>editClicked(form, eThePost, b));
1005 b.type = 'button';
1006 btnEdit.parentElement.insertBefore(b, btnEdit);
1007 btnEdit.remove();
1008 }
1009 })/*for-each form*/;
1010 }/* /forumpost and /forumthread */
1011
1012 if( 0 ){
1013 /* TODO every now and then, not every request... */
1014 F.ForumPostEditor.purgeOldDrafts(/^draft-forum.*/);
1015 }
1016 })/*F.onPageLoad callback*/;
1017 })(window.fossil);
1018
--- src/style.forum.css
+++ src/style.forum.css
@@ -2,10 +2,11 @@
22
33
.ForumPostEditor {
44
display: flex;
55
flex-direction: column;
66
gap: 1em;
7
+ padding: 0.5em;
78
}
89
910
.ForumPostEditor > .tab-bar{
1011
}
1112
1213
--- src/style.forum.css
+++ src/style.forum.css
@@ -2,10 +2,11 @@
2
3 .ForumPostEditor {
4 display: flex;
5 flex-direction: column;
6 gap: 1em;
 
7 }
8
9 .ForumPostEditor > .tab-bar{
10 }
11
12
--- src/style.forum.css
+++ src/style.forum.css
@@ -2,10 +2,11 @@
2
3 .ForumPostEditor {
4 display: flex;
5 flex-direction: column;
6 gap: 1em;
7 padding: 0.5em;
8 }
9
10 .ForumPostEditor > .tab-bar{
11 }
12
13

Keyboard Shortcuts

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