Fossil SCM

fossil-scm / src / fossil.page.pikchrshow.js
Blame History Raw 624 lines
1
(function(F/*the fossil object*/){
2
"use strict";
3
/**
4
Client-side implementation of the /pikchrshowcs app. Requires that
5
the fossil JS bootstrapping is complete and that these fossil JS
6
APIs have been installed: fossil.fetch, fossil.dom,
7
fossil.copybutton, fossil.popupwidget, fossil.storage
8
9
Maintenance funkiness note: this file is for the legacy
10
/pikchrshowcs app, which was formerly named /pikchrshow. This
11
file and its replacement were not renamed because the replacement
12
impl would end up getting this file's name and cause confusion in
13
the file history. Whether that confusion would be less than this
14
file's name matching the _other_ /pikchrshow impl will cause more
15
or less confusion than that remains to be seen.
16
*/
17
const E = (s)=>document.querySelector(s),
18
D = F.dom,
19
P = F.page;
20
21
P.previewMode = 0 /*0==rendered SVG, 1==pikchr text markdown,
22
2==pikchr text fossil, 3==raw SVG. */
23
P.response = {/*stashed state for the server's preview response*/
24
isError: false,
25
inputText: undefined /* value of the editor field at render-time */,
26
raw: undefined /* raw response text/HTML from server */,
27
rawSvg: undefined /* plain-text SVG part of responses. Required
28
because the browser will convert \u00a0 to
29
  if we extract the SVG from the DOM,
30
resulting in illegal SVG. */
31
};
32
33
/**
34
If string r contains an SVG element, this returns that section
35
of the string, else it returns falsy.
36
*/
37
const getResponseSvg = function(r){
38
const i0 = r.indexOf("<svg");
39
if(i0>=0){
40
const i1 = r.indexOf("</svg");
41
return r.substring(i0,i1+6);
42
}
43
return '';
44
};
45
46
F.onPageLoad(function() {
47
document.body.classList.add('pikchrshow');
48
P.e = { /* various DOM elements we work with... */
49
previewTarget: E('#pikchrshow-output'),
50
previewLegend: E('#pikchrshow-output-wrapper > legend'),
51
previewCopyButton: D.attr(
52
D.addClass(D.button(),'copy-button'),
53
'id','preview-copy-button'
54
),
55
previewModeLabel: D.label('preview-copy-button'),
56
btnSubmit: E('#pikchr-submit-preview'),
57
btnStash: E('#pikchr-stash'),
58
btnUnstash: E('#pikchr-unstash'),
59
btnClearStash: E('#pikchr-clear-stash'),
60
cbDarkMode: E('#flipcolors-wrapper > input[type=checkbox]'),
61
taContent: E('#content'),
62
taPreviewText: D.textarea(20,0,true),
63
uiControls: E('#pikchrshow-controls'),
64
previewModeToggle: D.button("Preview mode"),
65
markupAlignDefault: D.attr(D.radio('markup-align','',true),
66
'id','markup-align-default'),
67
markupAlignCenter: D.attr(D.radio('markup-align','center'),
68
'id','markup-align-center'),
69
markupAlignIndent: D.attr(D.radio('markup-align','indent'),
70
'id','markup-align-indent'),
71
markupAlignWrapper: D.addClass(D.span(), 'input-with-label')
72
};
73
74
////////////////////////////////////////////////////////////
75
// Setup markup alignment selection...
76
const alignEvent = function(ev){
77
/* Update markdown/fossil wiki preview if it's active */
78
if(P.previewMode==1 || P.previewMode==2){
79
P.renderPreview();
80
}
81
};
82
P.e.markupAlignRadios = [
83
P.e.markupAlignDefault,
84
P.e.markupAlignCenter,
85
P.e.markupAlignIndent
86
];
87
D.append(P.e.markupAlignWrapper,
88
D.addClass(D.append(D.span(),"align:"),
89
'v-align-middle'));
90
P.e.markupAlignRadios.forEach(
91
function(e){
92
e.addEventListener('change', alignEvent, false);
93
D.append(P.e.markupAlignWrapper,
94
D.addClass([
95
e,
96
D.label(e, e.value || "left")
97
], 'v-align-middle'));
98
}
99
);
100
101
////////////////////////////////////////////////////////////
102
// Setup the preview fieldset's LEGEND element...
103
D.append( P.e.previewLegend,
104
P.e.previewModeToggle,
105
'\u00a0',
106
P.e.previewCopyButton,
107
P.e.previewModeLabel,
108
P.e.markupAlignWrapper );
109
110
////////////////////////////////////////////////////////////
111
// Trigger preview on Shift-Enter.
112
P.e.taContent.addEventListener('keydown',function(ev){
113
if(ev.shiftKey && 13 === ev.keyCode){
114
ev.preventDefault();
115
ev.stopPropagation();
116
P.preview();
117
return false;
118
}
119
}, false);
120
121
////////////////////////////////////////////////////////////
122
// Setup clipboard-copy of markup/SVG...
123
F.copyButton(P.e.previewCopyButton, {copyFromElement: P.e.taPreviewText});
124
125
////////////////////////////////////////////////////////////
126
// Set up dark mode simulator...
127
P.e.cbDarkMode.addEventListener('change', function(ev){
128
if(ev.target.checked) D.addClass(P.e.previewTarget, 'dark-mode');
129
else D.removeClass(P.e.previewTarget, 'dark-mode');
130
}, false);
131
if(P.e.cbDarkMode.checked) D.addClass(P.e.previewTarget, 'dark-mode');
132
133
////////////////////////////////////////////////////////////
134
// Set up preview update and preview mode toggle...
135
P.e.btnSubmit.addEventListener('click', ()=>P.preview(), false);
136
P.e.previewModeToggle.addEventListener('click', function(){
137
/* Rotate through the 4 available preview modes */
138
P.previewMode = ++P.previewMode % 4;
139
P.renderPreview();
140
}, false);
141
142
////////////////////////////////////////////////////////////
143
// Set up selection list of predefined scripts...
144
if(true){
145
const selectScript = P.e.selectScript = D.select(),
146
cbAutoPreview = P.e.cbAutoPreview =
147
D.attr(D.checkbox(true),'id', 'cb-auto-preview'),
148
cbWrap = D.addClass(D.div(),'input-with-label')
149
;
150
D.append(
151
cbWrap,
152
selectScript,
153
cbAutoPreview,
154
D.label(cbAutoPreview,"Auto-preview?"),
155
F.helpButtonlets.create(
156
D.append(D.div(),
157
'Auto-preview automatically previews selected ',
158
'built-in pikchr scripts by sending them to ',
159
'the server for rendering. Not recommended on a ',
160
'slow connection/server.',
161
D.br(),D.br(),
162
'Pikchr scripts may also be dragged/dropped from ',
163
'the local filesystem into the text area, if the ',
164
'environment supports it, but the auto-preview ',
165
'option does not apply to them.'
166
)
167
)
168
)/*.childNodes.forEach(function(ch){
169
ch.style.margin = "0 0.25em";
170
})*/;
171
D.append(P.e.uiControls, cbWrap);
172
P.predefinedPiks.forEach(function(script,ndx){
173
const opt = D.option(script.code ? script.code.trim() :'', script.name);
174
D.append(selectScript, opt);
175
opt.$_sampleScript = script /* for response caching purposes */;
176
if(!ndx) selectScript.selectedIndex = 0 /*timing/ordering workaround*/;
177
if(!script.code) D.disable(opt);
178
});
179
delete P.predefinedPiks;
180
selectScript.addEventListener('change', function(ev){
181
const val = ev.target.value;
182
if(!val) return;
183
const opt = ev.target.selectedOptions[0];
184
P.e.taContent.value = val;
185
if(cbAutoPreview.checked){
186
P.preview.$_sampleScript = opt.$_sampleScript;
187
P.preview();
188
}
189
}, false);
190
}
191
192
////////////////////////////////////////////////////////////
193
// Move dark mode checkbox to the end and add a help buttonlet
194
D.append(
195
P.e.uiControls,
196
D.append(
197
P.e.cbDarkMode.parentNode/*the .input-with-label element*/,
198
F.helpButtonlets.create(
199
D.div(),
200
'Dark mode changes the colors of rendered SVG to ',
201
'make them more visible in dark-themed skins. ',
202
'This only changes (using CSS) how they are rendered, ',
203
'not any actual colors written in the script.',
204
D.br(), D.br(),
205
'In some color combinations, certain browsers might ',
206
'cause the SVG image to blur considerably with this ',
207
'setting enabled!'
208
)
209
)
210
);
211
212
////////////////////////////////////////////////////////////
213
// File drag/drop pikchr scripts into P.e.taContent.
214
// Adapted from: https://stackoverflow.com/a/58677161
215
const dropHighlight = P.e.taContent;
216
const dropEvents = {
217
drop: function(ev){
218
//ev.stopPropagation();
219
ev.preventDefault();
220
D.removeClass(dropHighlight, 'dragover');
221
const file = ev.dataTransfer.files[0];
222
if(file) {
223
const reader = new FileReader();
224
reader.addEventListener(
225
'load', function(e) {P.e.taContent.value = e.target.result}, false
226
);
227
reader.readAsText(file, "UTF-8");
228
}
229
},
230
dragenter: function(ev){
231
//ev.stopPropagation();
232
ev.preventDefault();
233
ev.dataTransfer.dropEffect = "copy";
234
D.addClass(dropHighlight, 'dragover');
235
//console.debug("dragenter");
236
},
237
dragover: function(ev){
238
//ev.stopPropagation();
239
ev.preventDefault();
240
//console.debug("dragover");
241
},
242
dragend: function(ev){
243
//ev.stopPropagation();
244
ev.preventDefault();
245
//console.debug("dragend");
246
},
247
dragleave: function(ev){
248
//ev.stopPropagation();
249
ev.preventDefault();
250
D.removeClass(dropHighlight, 'dragover');
251
//console.debug("dragleave");
252
}
253
};
254
/*
255
The idea here is to accept drops at multiple points or, ideally,
256
document.body, and apply them to P.e.taContent, but the precise
257
combination of event handling needed to pull this off is eluding
258
me.
259
*/
260
[P.e.taContent
261
//P.e.previewTarget,// works only until we drag over the SVG element!
262
//document.body
263
/* ideally we'd link only to document.body, but the events seem to
264
get out of whack, with dropleave being triggered
265
at unexpected points. */
266
].forEach(function(e){
267
Object.keys(dropEvents).forEach(
268
(k)=>e.addEventListener(k, dropEvents[k], true)
269
);
270
});
271
272
////////////////////////////////////////////////////////////
273
// Setup stash/unstash
274
const stashKey = 'pikchrshow-stash';
275
P.e.btnStash.addEventListener('click', function(){
276
const val = P.e.taContent.value;
277
if(val){
278
F.storage.set(stashKey, val);
279
D.enable(P.e.btnUnstash);
280
F.toast.message("Stashed pikchr.");
281
}
282
}, false);
283
P.e.btnUnstash.addEventListener('click', function(){
284
const val = F.storage.get(stashKey);
285
P.e.taContent.value = val || '';
286
}, false);
287
P.e.btnClearStash.addEventListener('click', function(){
288
F.storage.remove(stashKey);
289
D.disable(P.e.btnUnstash);
290
F.toast.message("Cleared pikchr stash.");
291
}, false);
292
F.helpButtonlets.create(P.e.btnClearStash.nextElementSibling);
293
// If we have stashed contents, enable Unstash, else disable it:
294
if(F.storage.contains(stashKey)) D.enable(P.e.btnUnstash);
295
else D.disable(P.e.btnUnstash);
296
297
////////////////////////////////////////////////////////////
298
// If we start with content, get it in sync with the state
299
// generated by P.preview(). Normally the server pre-populates it
300
// with an example.
301
let needsPreview;
302
if(!P.e.taContent.value){
303
P.e.taContent.value = F.storage.get(stashKey,'');
304
needsPreview = true;
305
}
306
if(P.e.taContent.value){
307
/* Fill our "response" state so that renderPreview() can work */
308
P.response.inputText = P.e.taContent.value;
309
P.response.raw = P.e.previewTarget.innerHTML;
310
P.response.rawSvg = getResponseSvg(
311
P.response.raw /*note that this is already in the DOM,
312
which means that the browser has already mangled
313
\u00a0 to &nbsp;, so...*/.split('&nbsp;').join('\u00a0'));
314
if(needsPreview) P.preview();
315
else{
316
/*If it's from the server, it's already rendered, but this
317
gets all labels/headers in sync.*/
318
P.renderPreview();
319
}
320
}
321
}/*F.onPageLoad()*/);
322
323
/**
324
Updates the preview view based on the current preview mode and
325
error state.
326
*/
327
P.renderPreview = function f(){
328
if(!f.hasOwnProperty('rxNonce')){
329
f.rxNonce = /<!--.+-->\r?\n?/g /*pikchr nonce comments*/;
330
f.showMarkupAlignment = function(showIt){
331
P.e.markupAlignWrapper.classList[showIt ? 'remove' : 'add']('hidden');
332
};
333
f.getMarkupAlignmentClass = function(){
334
if(P.e.markupAlignCenter.checked) return ' center';
335
else if(P.e.markupAlignIndent.checked) return ' indent';
336
return '';
337
};
338
f.getSvgNode = function(txt){
339
const childs = D.parseHtml(txt);
340
const wrapper = childs.filter((e)=>'DIV'===e.tagName)[0];
341
return wrapper ? wrapper.querySelector('svg.pikchr') : undefined;
342
};
343
}
344
const preTgt = this.e.previewTarget;
345
if(this.response.isError){
346
D.append(D.clearElement(preTgt), D.parseHtml(P.response.raw));
347
D.addClass(preTgt, 'error');
348
this.e.previewModeLabel.innerText = "Error";
349
return;
350
}
351
D.removeClass(preTgt, 'error');
352
this.e.previewCopyButton.disabled = false;
353
D.removeClass(this.e.markupAlignWrapper, 'hidden');
354
D.enable(this.e.previewModeToggle, this.e.markupAlignRadios);
355
let label, svg;
356
switch(this.previewMode){
357
case 0:
358
label = "SVG";
359
f.showMarkupAlignment(false);
360
D.parseHtml(D.clearElement(preTgt), P.response.raw);
361
svg = preTgt.querySelector('svg.pikchr');
362
if(svg && P.response.rawSvg){ /*for copy button*/
363
this.e.taPreviewText.value = P.response.rawSvg;
364
F.pikchr.addSrcView(svg);
365
}
366
break;
367
case 1:
368
label = "Markdown";
369
f.showMarkupAlignment(true);
370
this.e.taPreviewText.value = [
371
'```pikchr'+f.getMarkupAlignmentClass(),
372
this.response.inputText.trim(), '```'
373
].join('\n');
374
D.append(D.clearElement(preTgt), this.e.taPreviewText);
375
break;
376
case 2:
377
label = "Fossil wiki";
378
f.showMarkupAlignment(true);
379
this.e.taPreviewText.value = [
380
'<verbatim type="pikchr',
381
f.getMarkupAlignmentClass(),
382
'">', this.response.inputText.trim(), '</verbatim>'
383
].join('');
384
D.append(D.clearElement(preTgt), this.e.taPreviewText);
385
break;
386
case 3:
387
label = "Raw SVG";
388
f.showMarkupAlignment(false);
389
svg = f.getSvgNode(this.response.raw);
390
if(svg){
391
this.e.taPreviewText.value =
392
P.response.rawSvg || "Error extracting SVG element.";
393
}else{
394
this.e.taPreviewText.value = "ERROR parsing response HTML:\n"+
395
this.response.raw;
396
console.error("svg parsed HTML nodes:",childs);
397
}
398
D.append(D.clearElement(preTgt), this.e.taPreviewText);
399
break;
400
}
401
this.e.previewModeLabel.innerText = label;
402
this.e.taContent.focus(/*not sure why this gets lost on preview!*/);
403
};
404
405
/**
406
Fetches the preview from the server and updates the preview to
407
the rendered SVG content or error report.
408
*/
409
P.preview = function fp(){
410
if(!fp.hasOwnProperty('toDisable')){
411
fp.toDisable = [
412
/* input elements to disable during ajax operations */
413
this.e.btnSubmit, this.e.taContent,
414
this.e.cbAutoPreview, this.e.selectScript,
415
this.e.btnStash, this.e.btnClearStash
416
/* handled separately: previewModeToggle, previewCopyButton,
417
markupAlignRadios */
418
];
419
fp.target = this.e.previewTarget;
420
fp.updateView = function(c,isError){
421
P.previewMode = 0;
422
P.response.raw = c;
423
P.response.rawSvg = getResponseSvg(c);
424
P.response.isError = isError;
425
D.enable(fp.toDisable);
426
P.renderPreview();
427
};
428
}
429
D.disable(fp.toDisable, this.e.previewModeToggle, this.e.markupAlignRadios);
430
D.addClass(this.e.markupAlignWrapper, 'hidden');
431
this.e.previewCopyButton.disabled = true;
432
const content = this.e.taContent.value.trim();
433
this.response.raw = this.response.rawSvg = undefined;
434
this.response.inputText = content;
435
const sampleScript = fp.$_sampleScript;
436
delete fp.$_sampleScript;
437
if(sampleScript && sampleScript.cached){
438
fp.updateView(sampleScript.cached, false);
439
return this;
440
}
441
if(!content){
442
fp.updateView("No pikchr content!",true);
443
return this;
444
}
445
const self = this;
446
const fd = new FormData();
447
fd.append('ajax', true);
448
fd.append('content',content);
449
F.fetch('pikchrshow',{
450
payload: fd,
451
responseHeaders: 'x-pikchrshow-is-error',
452
onload: (r,isErrHeader)=>{
453
const isErr = +isErrHeader ? true : false;
454
if(!isErr && sampleScript){
455
sampleScript.cached = r;
456
}
457
fp.updateView(r,isErr);
458
},
459
onerror: (e)=>{
460
F.fetch.onerror(e);
461
fp.updateView("Error fetching preview: "+e, true);
462
}
463
});
464
return this;
465
}/*preview()*/;
466
467
/**
468
Predefined scripts. Each entry is an object:
469
470
{
471
name: required string,
472
473
code: optional code string. An entry with no code
474
is treated like a separator in the resulting
475
SELECT element (a disabled OPTION).
476
477
}
478
*/
479
P.predefinedPiks = [
480
{name: "-- Example Scripts --"},
481
/*
482
The following were imported from the pikchr test scripts:
483
484
https://fossil-scm.org/pikchr/dir/examples
485
*/
486
{name:"Cardinal headings",code:` linerad = 5px
487
C: circle "Center" rad 150%
488
circle "N" at 1.0 n of C; arrow from C to last chop ->
489
circle "NE" at 1.0 ne of C; arrow from C to last chop <-
490
circle "E" at 1.0 e of C; arrow from C to last chop <->
491
circle "SE" at 1.0 se of C; arrow from C to last chop ->
492
circle "S" at 1.0 s of C; arrow from C to last chop <-
493
circle "SW" at 1.0 sw of C; arrow from C to last chop <->
494
circle "W" at 1.0 w of C; arrow from C to last chop ->
495
circle "NW" at 1.0 nw of C; arrow from C to last chop <-
496
arrow from 2nd circle to 3rd circle chop
497
arrow from 4th circle to 3rd circle chop
498
arrow from SW to S chop <->
499
circle "ESE" at 2.0 heading 112.5 from Center \
500
thickness 150% fill lightblue radius 75%
501
arrow from Center to ESE thickness 150% <-> chop
502
arrow from ESE up 1.35 then to NE chop
503
line dashed <- from E.e to (ESE.x,E.y)
504
line dotted <-> thickness 50% from N to NW chop
505
`},{name:"Core object types",code:`AllObjects: [
506
507
# First row of objects
508
box "box"
509
box rad 10px "box (with" "rounded" "corners)" at 1in right of previous
510
circle "circle" at 1in right of previous
511
ellipse "ellipse" at 1in right of previous
512
513
# second row of objects
514
OVAL1: oval "oval" at 1in below first box
515
oval "(tall &" "thin)" "oval" width OVAL1.height height OVAL1.width \
516
at 1in right of previous
517
cylinder "cylinder" at 1in right of previous
518
file "file" at 1in right of previous
519
520
# third row shows line-type objects
521
dot "dot" above at 1in below first oval
522
line right from 1.8cm right of previous "lines" above
523
arrow right from 1.8cm right of previous "arrows" above
524
spline from 1.8cm right of previous \
525
go right .15 then .3 heading 30 then .5 heading 160 then .4 heading 20 \
526
then right .15
527
"splines" at 3rd vertex of previous
528
529
# The third vertex of the spline is not actually on the drawn
530
# curve. The third vertex is a control point. To see its actual
531
# position, uncomment the following line:
532
#dot color red at 3rd vertex of previous spline
533
534
# Draw various lines below the first line
535
line dashed right from 0.3cm below start of previous line
536
line dotted right from 0.3cm below start of previous
537
line thin right from 0.3cm below start of previous
538
line thick right from 0.3cm below start of previous
539
540
541
# Draw arrows with different arrowhead configurations below
542
# the first arrow
543
arrow <- right from 0.4cm below start of previous arrow
544
arrow <-> right from 0.4cm below start of previous
545
546
# Draw splines with different arrowhead configurations below
547
# the first spline
548
spline same from .4cm below start of first spline ->
549
spline same from .4cm below start of previous <-
550
spline same from .4cm below start of previous <->
551
552
] # end of AllObjects
553
554
# Label the whole diagram
555
text "Examples Of Pikchr Objects" big bold at .8cm above north of AllObjects
556
`},{name:"Swimlanes",code:` $laneh = 0.75
557
558
# Draw the lanes
559
down
560
box width 3.5in height $laneh fill 0xacc9e3
561
box same fill 0xc5d8ef
562
box same as first box
563
box same as 2nd box
564
line from 1st box.sw+(0.2,0) up until even with 1st box.n \
565
"Alan" above aligned
566
line from 2nd box.sw+(0.2,0) up until even with 2nd box.n \
567
"Betty" above aligned
568
line from 3rd box.sw+(0.2,0) up until even with 3rd box.n \
569
"Charlie" above aligned
570
line from 4th box.sw+(0.2,0) up until even with 4th box.n \
571
"Darlene" above aligned
572
573
# fill in content for the Alice lane
574
right
575
A1: circle rad 0.1in at end of first line + (0.2,-0.2) \
576
fill white thickness 1.5px "1"
577
arrow right 50%
578
circle same "2"
579
arrow right until even with first box.e - (0.65,0.0)
580
ellipse "future" fit fill white height 0.2 width 0.5 thickness 1.5px
581
A3: circle same at A1+(0.8,-0.3) "3" fill 0xc0c0c0
582
arrow from A1 to last circle chop "fork!" below aligned
583
584
# content for the Betty lane
585
B1: circle same as A1 at A1-(0,$laneh) "1"
586
arrow right 50%
587
circle same "2"
588
arrow right until even with first ellipse.w
589
ellipse same "future"
590
B3: circle same at A3-(0,$laneh) "3"
591
arrow right 50%
592
circle same as A3 "4"
593
arrow from B1 to 2nd last circle chop
594
595
# content for the Charlie lane
596
C1: circle same as A1 at B1-(0,$laneh) "1"
597
arrow 50%
598
circle same "2"
599
arrow right 0.8in "goes" "offline"
600
C5: circle same as A3 "5"
601
arrow right until even with first ellipse.w \
602
"back online" above "pushes 5" below "pulls 3 & 4" below
603
ellipse same "future"
604
605
# content for the Darlene lane
606
D1: circle same as A1 at C1-(0,$laneh) "1"
607
arrow 50%
608
circle same "2"
609
arrow right until even with C5.w
610
circle same "5"
611
arrow 50%
612
circle same as A3 "6"
613
arrow right until even with first ellipse.w
614
ellipse same "future"
615
D3: circle same as B3 at B3-(0,2*$laneh) "3"
616
arrow 50%
617
circle same "4"
618
arrow from D1 to D3 chop
619
`}
620
621
];
622
623
})(window.fossil);
624

Keyboard Shortcuts

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