diff options
| author | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
| commit | 4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch) | |
| tree | c296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/fossil.page.pikchrshow.js | |
| download | relic-master.tar.gz | |
Diffstat (limited to 'src/fossil.page.pikchrshow.js')
| -rw-r--r-- | src/fossil.page.pikchrshow.js | 623 |
1 files changed, 623 insertions, 0 deletions
diff --git a/src/fossil.page.pikchrshow.js b/src/fossil.page.pikchrshow.js new file mode 100644 index 0000000..7abdb24 --- /dev/null +++ b/src/fossil.page.pikchrshow.js @@ -0,0 +1,623 @@ +(function(F/*the fossil object*/){ + "use strict"; + /** + Client-side implementation of the /pikchrshowcs app. Requires that + the fossil JS bootstrapping is complete and that these fossil JS + APIs have been installed: fossil.fetch, fossil.dom, + fossil.copybutton, fossil.popupwidget, fossil.storage + + Maintenance funkiness note: this file is for the legacy + /pikchrshowcs app, which was formerly named /pikchrshow. This + file and its replacement were not renamed because the replacement + impl would end up getting this file's name and cause confusion in + the file history. Whether that confusion would be less than this + file's name matching the _other_ /pikchrshow impl will cause more + or less confusion than that remains to be seen. + */ + const E = (s)=>document.querySelector(s), + D = F.dom, + P = F.page; + + P.previewMode = 0 /*0==rendered SVG, 1==pikchr text markdown, + 2==pikchr text fossil, 3==raw SVG. */ + P.response = {/*stashed state for the server's preview response*/ + isError: false, + inputText: undefined /* value of the editor field at render-time */, + raw: undefined /* raw response text/HTML from server */, + rawSvg: undefined /* plain-text SVG part of responses. Required + because the browser will convert \u00a0 to + if we extract the SVG from the DOM, + resulting in illegal SVG. */ + }; + + /** + If string r contains an SVG element, this returns that section + of the string, else it returns falsy. + */ + const getResponseSvg = function(r){ + const i0 = r.indexOf("<svg"); + if(i0>=0){ + const i1 = r.indexOf("</svg"); + return r.substring(i0,i1+6); + } + return ''; + }; + + F.onPageLoad(function() { + document.body.classList.add('pikchrshow'); + P.e = { /* various DOM elements we work with... */ + previewTarget: E('#pikchrshow-output'), + previewLegend: E('#pikchrshow-output-wrapper > legend'), + previewCopyButton: D.attr( + D.addClass(D.button(),'copy-button'), + 'id','preview-copy-button' + ), + previewModeLabel: D.label('preview-copy-button'), + btnSubmit: E('#pikchr-submit-preview'), + btnStash: E('#pikchr-stash'), + btnUnstash: E('#pikchr-unstash'), + btnClearStash: E('#pikchr-clear-stash'), + cbDarkMode: E('#flipcolors-wrapper > input[type=checkbox]'), + taContent: E('#content'), + taPreviewText: D.textarea(20,0,true), + uiControls: E('#pikchrshow-controls'), + previewModeToggle: D.button("Preview mode"), + markupAlignDefault: D.attr(D.radio('markup-align','',true), + 'id','markup-align-default'), + markupAlignCenter: D.attr(D.radio('markup-align','center'), + 'id','markup-align-center'), + markupAlignIndent: D.attr(D.radio('markup-align','indent'), + 'id','markup-align-indent'), + markupAlignWrapper: D.addClass(D.span(), 'input-with-label') + }; + + //////////////////////////////////////////////////////////// + // Setup markup alignment selection... + const alignEvent = function(ev){ + /* Update markdown/fossil wiki preview if it's active */ + if(P.previewMode==1 || P.previewMode==2){ + P.renderPreview(); + } + }; + P.e.markupAlignRadios = [ + P.e.markupAlignDefault, + P.e.markupAlignCenter, + P.e.markupAlignIndent + ]; + D.append(P.e.markupAlignWrapper, + D.addClass(D.append(D.span(),"align:"), + 'v-align-middle')); + P.e.markupAlignRadios.forEach( + function(e){ + e.addEventListener('change', alignEvent, false); + D.append(P.e.markupAlignWrapper, + D.addClass([ + e, + D.label(e, e.value || "left") + ], 'v-align-middle')); + } + ); + + //////////////////////////////////////////////////////////// + // Setup the preview fieldset's LEGEND element... + D.append( P.e.previewLegend, + P.e.previewModeToggle, + '\u00a0', + P.e.previewCopyButton, + P.e.previewModeLabel, + P.e.markupAlignWrapper ); + + //////////////////////////////////////////////////////////// + // Trigger preview on Shift-Enter. + P.e.taContent.addEventListener('keydown',function(ev){ + if(ev.shiftKey && 13 === ev.keyCode){ + ev.preventDefault(); + ev.stopPropagation(); + P.preview(); + return false; + } + }, false); + + //////////////////////////////////////////////////////////// + // Setup clipboard-copy of markup/SVG... + F.copyButton(P.e.previewCopyButton, {copyFromElement: P.e.taPreviewText}); + + //////////////////////////////////////////////////////////// + // Set up dark mode simulator... + P.e.cbDarkMode.addEventListener('change', function(ev){ + if(ev.target.checked) D.addClass(P.e.previewTarget, 'dark-mode'); + else D.removeClass(P.e.previewTarget, 'dark-mode'); + }, false); + if(P.e.cbDarkMode.checked) D.addClass(P.e.previewTarget, 'dark-mode'); + + //////////////////////////////////////////////////////////// + // Set up preview update and preview mode toggle... + P.e.btnSubmit.addEventListener('click', ()=>P.preview(), false); + P.e.previewModeToggle.addEventListener('click', function(){ + /* Rotate through the 4 available preview modes */ + P.previewMode = ++P.previewMode % 4; + P.renderPreview(); + }, false); + + //////////////////////////////////////////////////////////// + // Set up selection list of predefined scripts... + if(true){ + const selectScript = P.e.selectScript = D.select(), + cbAutoPreview = P.e.cbAutoPreview = + D.attr(D.checkbox(true),'id', 'cb-auto-preview'), + cbWrap = D.addClass(D.div(),'input-with-label') + ; + D.append( + cbWrap, + selectScript, + cbAutoPreview, + D.label(cbAutoPreview,"Auto-preview?"), + F.helpButtonlets.create( + D.append(D.div(), + 'Auto-preview automatically previews selected ', + 'built-in pikchr scripts by sending them to ', + 'the server for rendering. Not recommended on a ', + 'slow connection/server.', + D.br(),D.br(), + 'Pikchr scripts may also be dragged/dropped from ', + 'the local filesystem into the text area, if the ', + 'environment supports it, but the auto-preview ', + 'option does not apply to them.' + ) + ) + )/*.childNodes.forEach(function(ch){ + ch.style.margin = "0 0.25em"; + })*/; + D.append(P.e.uiControls, cbWrap); + P.predefinedPiks.forEach(function(script,ndx){ + const opt = D.option(script.code ? script.code.trim() :'', script.name); + D.append(selectScript, opt); + opt.$_sampleScript = script /* for response caching purposes */; + if(!ndx) selectScript.selectedIndex = 0 /*timing/ordering workaround*/; + if(!script.code) D.disable(opt); + }); + delete P.predefinedPiks; + selectScript.addEventListener('change', function(ev){ + const val = ev.target.value; + if(!val) return; + const opt = ev.target.selectedOptions[0]; + P.e.taContent.value = val; + if(cbAutoPreview.checked){ + P.preview.$_sampleScript = opt.$_sampleScript; + P.preview(); + } + }, false); + } + + //////////////////////////////////////////////////////////// + // Move dark mode checkbox to the end and add a help buttonlet + D.append( + P.e.uiControls, + D.append( + P.e.cbDarkMode.parentNode/*the .input-with-label element*/, + F.helpButtonlets.create( + D.div(), + 'Dark mode changes the colors of rendered SVG to ', + 'make them more visible in dark-themed skins. ', + 'This only changes (using CSS) how they are rendered, ', + 'not any actual colors written in the script.', + D.br(), D.br(), + 'In some color combinations, certain browsers might ', + 'cause the SVG image to blur considerably with this ', + 'setting enabled!' + ) + ) + ); + + //////////////////////////////////////////////////////////// + // File drag/drop pikchr scripts into P.e.taContent. + // Adapted from: https://stackoverflow.com/a/58677161 + const dropHighlight = P.e.taContent; + const dropEvents = { + drop: function(ev){ + //ev.stopPropagation(); + ev.preventDefault(); + D.removeClass(dropHighlight, 'dragover'); + const file = ev.dataTransfer.files[0]; + if(file) { + const reader = new FileReader(); + reader.addEventListener( + 'load', function(e) {P.e.taContent.value = e.target.result}, false + ); + reader.readAsText(file, "UTF-8"); + } + }, + dragenter: function(ev){ + //ev.stopPropagation(); + ev.preventDefault(); + ev.dataTransfer.dropEffect = "copy"; + D.addClass(dropHighlight, 'dragover'); + //console.debug("dragenter"); + }, + dragover: function(ev){ + //ev.stopPropagation(); + ev.preventDefault(); + //console.debug("dragover"); + }, + dragend: function(ev){ + //ev.stopPropagation(); + ev.preventDefault(); + //console.debug("dragend"); + }, + dragleave: function(ev){ + //ev.stopPropagation(); + ev.preventDefault(); + D.removeClass(dropHighlight, 'dragover'); + //console.debug("dragleave"); + } + }; + /* + The idea here is to accept drops at multiple points or, ideally, + document.body, and apply them to P.e.taContent, but the precise + combination of event handling needed to pull this off is eluding + me. + */ + [P.e.taContent + //P.e.previewTarget,// works only until we drag over the SVG element! + //document.body + /* ideally we'd link only to document.body, but the events seem to + get out of whack, with dropleave being triggered + at unexpected points. */ + ].forEach(function(e){ + Object.keys(dropEvents).forEach( + (k)=>e.addEventListener(k, dropEvents[k], true) + ); + }); + + //////////////////////////////////////////////////////////// + // Setup stash/unstash + const stashKey = 'pikchrshow-stash'; + P.e.btnStash.addEventListener('click', function(){ + const val = P.e.taContent.value; + if(val){ + F.storage.set(stashKey, val); + D.enable(P.e.btnUnstash); + F.toast.message("Stashed pikchr."); + } + }, false); + P.e.btnUnstash.addEventListener('click', function(){ + const val = F.storage.get(stashKey); + P.e.taContent.value = val || ''; + }, false); + P.e.btnClearStash.addEventListener('click', function(){ + F.storage.remove(stashKey); + D.disable(P.e.btnUnstash); + F.toast.message("Cleared pikchr stash."); + }, false); + F.helpButtonlets.create(P.e.btnClearStash.nextElementSibling); + // If we have stashed contents, enable Unstash, else disable it: + if(F.storage.contains(stashKey)) D.enable(P.e.btnUnstash); + else D.disable(P.e.btnUnstash); + + //////////////////////////////////////////////////////////// + // If we start with content, get it in sync with the state + // generated by P.preview(). Normally the server pre-populates it + // with an example. + let needsPreview; + if(!P.e.taContent.value){ + P.e.taContent.value = F.storage.get(stashKey,''); + needsPreview = true; + } + if(P.e.taContent.value){ + /* Fill our "response" state so that renderPreview() can work */ + P.response.inputText = P.e.taContent.value; + P.response.raw = P.e.previewTarget.innerHTML; + P.response.rawSvg = getResponseSvg( + P.response.raw /*note that this is already in the DOM, + which means that the browser has already mangled + \u00a0 to , so...*/.split(' ').join('\u00a0')); + if(needsPreview) P.preview(); + else{ + /*If it's from the server, it's already rendered, but this + gets all labels/headers in sync.*/ + P.renderPreview(); + } + } + }/*F.onPageLoad()*/); + + /** + Updates the preview view based on the current preview mode and + error state. + */ + P.renderPreview = function f(){ + if(!f.hasOwnProperty('rxNonce')){ + f.rxNonce = /<!--.+-->\r?\n?/g /*pikchr nonce comments*/; + f.showMarkupAlignment = function(showIt){ + P.e.markupAlignWrapper.classList[showIt ? 'remove' : 'add']('hidden'); + }; + f.getMarkupAlignmentClass = function(){ + if(P.e.markupAlignCenter.checked) return ' center'; + else if(P.e.markupAlignIndent.checked) return ' indent'; + return ''; + }; + f.getSvgNode = function(txt){ + const childs = D.parseHtml(txt); + const wrapper = childs.filter((e)=>'DIV'===e.tagName)[0]; + return wrapper ? wrapper.querySelector('svg.pikchr') : undefined; + }; + } + const preTgt = this.e.previewTarget; + if(this.response.isError){ + D.append(D.clearElement(preTgt), D.parseHtml(P.response.raw)); + D.addClass(preTgt, 'error'); + this.e.previewModeLabel.innerText = "Error"; + return; + } + D.removeClass(preTgt, 'error'); + this.e.previewCopyButton.disabled = false; + D.removeClass(this.e.markupAlignWrapper, 'hidden'); + D.enable(this.e.previewModeToggle, this.e.markupAlignRadios); + let label, svg; + switch(this.previewMode){ + case 0: + label = "SVG"; + f.showMarkupAlignment(false); + D.parseHtml(D.clearElement(preTgt), P.response.raw); + svg = preTgt.querySelector('svg.pikchr'); + if(svg && P.response.rawSvg){ /*for copy button*/ + this.e.taPreviewText.value = P.response.rawSvg; + F.pikchr.addSrcView(svg); + } + break; + case 1: + label = "Markdown"; + f.showMarkupAlignment(true); + this.e.taPreviewText.value = [ + '```pikchr'+f.getMarkupAlignmentClass(), + this.response.inputText.trim(), '```' + ].join('\n'); + D.append(D.clearElement(preTgt), this.e.taPreviewText); + break; + case 2: + label = "Fossil wiki"; + f.showMarkupAlignment(true); + this.e.taPreviewText.value = [ + '<verbatim type="pikchr', + f.getMarkupAlignmentClass(), + '">', this.response.inputText.trim(), '</verbatim>' + ].join(''); + D.append(D.clearElement(preTgt), this.e.taPreviewText); + break; + case 3: + label = "Raw SVG"; + f.showMarkupAlignment(false); + svg = f.getSvgNode(this.response.raw); + if(svg){ + this.e.taPreviewText.value = + P.response.rawSvg || "Error extracting SVG element."; + }else{ + this.e.taPreviewText.value = "ERROR parsing response HTML:\n"+ + this.response.raw; + console.error("svg parsed HTML nodes:",childs); + } + D.append(D.clearElement(preTgt), this.e.taPreviewText); + break; + } + this.e.previewModeLabel.innerText = label; + this.e.taContent.focus(/*not sure why this gets lost on preview!*/); + }; + + /** + Fetches the preview from the server and updates the preview to + the rendered SVG content or error report. + */ + P.preview = function fp(){ + if(!fp.hasOwnProperty('toDisable')){ + fp.toDisable = [ + /* input elements to disable during ajax operations */ + this.e.btnSubmit, this.e.taContent, + this.e.cbAutoPreview, this.e.selectScript, + this.e.btnStash, this.e.btnClearStash + /* handled separately: previewModeToggle, previewCopyButton, + markupAlignRadios */ + ]; + fp.target = this.e.previewTarget; + fp.updateView = function(c,isError){ + P.previewMode = 0; + P.response.raw = c; + P.response.rawSvg = getResponseSvg(c); + P.response.isError = isError; + D.enable(fp.toDisable); + P.renderPreview(); + }; + } + D.disable(fp.toDisable, this.e.previewModeToggle, this.e.markupAlignRadios); + D.addClass(this.e.markupAlignWrapper, 'hidden'); + this.e.previewCopyButton.disabled = true; + const content = this.e.taContent.value.trim(); + this.response.raw = this.response.rawSvg = undefined; + this.response.inputText = content; + const sampleScript = fp.$_sampleScript; + delete fp.$_sampleScript; + if(sampleScript && sampleScript.cached){ + fp.updateView(sampleScript.cached, false); + return this; + } + if(!content){ + fp.updateView("No pikchr content!",true); + return this; + } + const self = this; + const fd = new FormData(); + fd.append('ajax', true); + fd.append('content',content); + F.fetch('pikchrshow',{ + payload: fd, + responseHeaders: 'x-pikchrshow-is-error', + onload: (r,isErrHeader)=>{ + const isErr = +isErrHeader ? true : false; + if(!isErr && sampleScript){ + sampleScript.cached = r; + } + fp.updateView(r,isErr); + }, + onerror: (e)=>{ + F.fetch.onerror(e); + fp.updateView("Error fetching preview: "+e, true); + } + }); + return this; + }/*preview()*/; + + /** + Predefined scripts. Each entry is an object: + + { + name: required string, + + code: optional code string. An entry with no code + is treated like a separator in the resulting + SELECT element (a disabled OPTION). + + } + */ + P.predefinedPiks = [ + {name: "-- Example Scripts --"}, +/* + The following were imported from the pikchr test scripts: + + https://fossil-scm.org/pikchr/dir/examples +*/ +{name:"Cardinal headings",code:` linerad = 5px +C: circle "Center" rad 150% + circle "N" at 1.0 n of C; arrow from C to last chop -> + circle "NE" at 1.0 ne of C; arrow from C to last chop <- + circle "E" at 1.0 e of C; arrow from C to last chop <-> + circle "SE" at 1.0 se of C; arrow from C to last chop -> + circle "S" at 1.0 s of C; arrow from C to last chop <- + circle "SW" at 1.0 sw of C; arrow from C to last chop <-> + circle "W" at 1.0 w of C; arrow from C to last chop -> + circle "NW" at 1.0 nw of C; arrow from C to last chop <- + arrow from 2nd circle to 3rd circle chop + arrow from 4th circle to 3rd circle chop + arrow from SW to S chop <-> + circle "ESE" at 2.0 heading 112.5 from Center \ + thickness 150% fill lightblue radius 75% + arrow from Center to ESE thickness 150% <-> chop + arrow from ESE up 1.35 then to NE chop + line dashed <- from E.e to (ESE.x,E.y) + line dotted <-> thickness 50% from N to NW chop +`},{name:"Core object types",code:`AllObjects: [ + +# First row of objects +box "box" +box rad 10px "box (with" "rounded" "corners)" at 1in right of previous +circle "circle" at 1in right of previous +ellipse "ellipse" at 1in right of previous + +# second row of objects +OVAL1: oval "oval" at 1in below first box +oval "(tall &" "thin)" "oval" width OVAL1.height height OVAL1.width \ + at 1in right of previous +cylinder "cylinder" at 1in right of previous +file "file" at 1in right of previous + +# third row shows line-type objects +dot "dot" above at 1in below first oval +line right from 1.8cm right of previous "lines" above +arrow right from 1.8cm right of previous "arrows" above +spline from 1.8cm right of previous \ + go right .15 then .3 heading 30 then .5 heading 160 then .4 heading 20 \ + then right .15 +"splines" at 3rd vertex of previous + +# The third vertex of the spline is not actually on the drawn +# curve. The third vertex is a control point. To see its actual +# position, uncomment the following line: +#dot color red at 3rd vertex of previous spline + +# Draw various lines below the first line +line dashed right from 0.3cm below start of previous line +line dotted right from 0.3cm below start of previous +line thin right from 0.3cm below start of previous +line thick right from 0.3cm below start of previous + + +# Draw arrows with different arrowhead configurations below +# the first arrow +arrow <- right from 0.4cm below start of previous arrow +arrow <-> right from 0.4cm below start of previous + +# Draw splines with different arrowhead configurations below +# the first spline +spline same from .4cm below start of first spline -> +spline same from .4cm below start of previous <- +spline same from .4cm below start of previous <-> + +] # end of AllObjects + +# Label the whole diagram +text "Examples Of Pikchr Objects" big bold at .8cm above north of AllObjects +`},{name:"Swimlanes",code:` $laneh = 0.75 + + # Draw the lanes + down + box width 3.5in height $laneh fill 0xacc9e3 + box same fill 0xc5d8ef + box same as first box + box same as 2nd box + line from 1st box.sw+(0.2,0) up until even with 1st box.n \ + "Alan" above aligned + line from 2nd box.sw+(0.2,0) up until even with 2nd box.n \ + "Betty" above aligned + line from 3rd box.sw+(0.2,0) up until even with 3rd box.n \ + "Charlie" above aligned + line from 4th box.sw+(0.2,0) up until even with 4th box.n \ + "Darlene" above aligned + + # fill in content for the Alice lane + right +A1: circle rad 0.1in at end of first line + (0.2,-0.2) \ + fill white thickness 1.5px "1" + arrow right 50% + circle same "2" + arrow right until even with first box.e - (0.65,0.0) + ellipse "future" fit fill white height 0.2 width 0.5 thickness 1.5px +A3: circle same at A1+(0.8,-0.3) "3" fill 0xc0c0c0 + arrow from A1 to last circle chop "fork!" below aligned + + # content for the Betty lane +B1: circle same as A1 at A1-(0,$laneh) "1" + arrow right 50% + circle same "2" + arrow right until even with first ellipse.w + ellipse same "future" +B3: circle same at A3-(0,$laneh) "3" + arrow right 50% + circle same as A3 "4" + arrow from B1 to 2nd last circle chop + + # content for the Charlie lane +C1: circle same as A1 at B1-(0,$laneh) "1" + arrow 50% + circle same "2" + arrow right 0.8in "goes" "offline" +C5: circle same as A3 "5" + arrow right until even with first ellipse.w \ + "back online" above "pushes 5" below "pulls 3 & 4" below + ellipse same "future" + + # content for the Darlene lane +D1: circle same as A1 at C1-(0,$laneh) "1" + arrow 50% + circle same "2" + arrow right until even with C5.w + circle same "5" + arrow 50% + circle same as A3 "6" + arrow right until even with first ellipse.w + ellipse same "future" +D3: circle same as B3 at B3-(0,2*$laneh) "3" + arrow 50% + circle same "4" + arrow from D1 to D3 chop +`} + + ]; + +})(window.fossil); |
