From 28ff16b50f73973047e41497c9a036d3ee1bc17e Mon Sep 17 00:00:00 2001 From: Nathan Carter Date: Thu, 26 Oct 2017 09:52:32 -0400 Subject: [PATCH] Latest platform build --- release/lurch-web-platform.js | 2 +- release/lurch-web-platform.js.map | 2 +- release/lurch-web-platform.litcoffee | 38 +++++++++++++++++++--------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/release/lurch-web-platform.js b/release/lurch-web-platform.js index 158656f..8d2c21d 100644 --- a/release/lurch-web-platform.js +++ b/release/lurch-web-platform.js @@ -1,2 +1,2 @@ -var Dependencies,Dialogs,DownloadUpload,Group,Groups,Overlay,Storage,addToCache,cacheLookup,createFontStyleString,createStyleString,drawHTMLCache,editor,exportPage,formatContentForWiki,getAPIPage,getIndexPage,getPageContent,getPageData,getPageMetadata,getPageTimestamp,grouperHTML,grouperInfo,htmlToImage,importPage,installClickListener,keyboardShortcutsWorkaround,login,markUsed,maybeSetupTestRecorder,moreMenuItems,moreToolbarItems,plugin,prepareHTML,pruneCache,setAPIPage,setIndexPage,styleSheet,hasProp={}.hasOwnProperty,indexOf=[].indexOf||function(t){for(var e=0,n=this.length;e0;)e=e.childNodes[0];return e},t.Node.prototype.previousLeaf=function(t){var e;for(null==t&&(t=null),e=this;e&&e!==t&&!e.previousSibling;)e=e.parentNode;if(e===t)return null;if(!(e=null!=e?e.previousSibling:void 0))return null;for(;e.childNodes.length>0;)e=e.childNodes[e.childNodes.length-1];return e},t.Node.prototype.nextTextNode=function(e){var n;return null==e&&(e=null),(n=this.nextLeaf(e))instanceof t.Text?n:null!=n?n.nextTextNode(e):void 0},t.Node.prototype.previousTextNode=function(e){var n;return null==e&&(e=null),(n=this.previousLeaf(e))instanceof t.Text?n:null!=n?n.previousTextNode(e):void 0},t.Node.prototype.firstLeafInside=function(){var t,e;return(null!=(t=this.childNodes)&&null!=(e=t[0])?e.firstLeafInside():void 0)||this},t.Node.prototype.lastLeafInside=function(){var t,e;return(null!=(t=this.childNodes)&&null!=(e=t[this.childNodes.length-1])?e.lastLeafInside():void 0)||this},t.Node.prototype.remove=function(){var t;return null!=(t=this.parentNode)?t.removeChild(this):void 0},t.Element.prototype.hasClass=function(t){var e,n;return(e=null!=(n=this.getAttribute("class"))?n.split(/\s+/):void 0)&&indexOf.call(e,t)>=0},t.Element.prototype.addClass=function(t){var e,n;return e=(null!=(n=this.getAttribute("class"))?n.split(/\s+/):void 0)||[],indexOf.call(e,t)<0&&e.push(t),this.setAttribute("class",e.join(" "))},t.Element.prototype.removeClass=function(t){var e,n,o;return n=(null!=(o=this.getAttribute("class"))?o.split(/\s+/):void 0)||[],(n=function(){var o,i,r;for(r=[],o=0,i=n.length;o0?this.setAttribute("class",n.join(" ")):this.removeAttribute("class")},t.document.nodeFromPoint=function(e,n){var o,i,r,s,l,a,u,c,d,p;for(i=0,s=(d=(o=t.document.elementFromPoint(e,n)).childNodes).length;i0){if(!(this.endContainer instanceof t.Text)){if(this.endOffset>0?o=this.endContainer.childNodes[this.endOffset-1].nextTextNode(t.document.body):(o=this.endContainer.firstLeafInside())instanceof t.Text||(o=o.nextTextNode(t.document.body)),!o)return!1;this.setEnd(o,0)}if(n=this.endContainer.length-this.endOffset,e<=n)return this.setEnd(this.endContainer,this.endOffset+e),!0;if(o=this.endContainer.nextTextNode(t.document.body))return this.setEnd(o,0),this.extendByCharacters(e-n)}else if(e<0){if(!(this.startContainer instanceof t.Text)){if(this.startOffset>0?i=this.startContainer.childNodes[this.startOffset-1].previousTextNode(t.document.body):(i=this.startContainer.lastLeafInside())instanceof t.Text||(i=i.previousTextNode(t.document.body)),!i)return!1;this.setStart(i,0)}if(-e<=this.startOffset)return this.setStart(this.startContainer,this.startOffset+e),!0;if(i=this.startContainer.previousTextNode(t.document.body))return r=e+this.startOffset,this.setStart(i,i.length),this.extendByCharacters(r)}return!1},e=function(t){return!/\s/.test(t)},t.Range.prototype.firstCharacter=function(){return this.toString().charAt(0)},t.Range.prototype.lastCharacter=function(){return this.toString().charAt(this.toString().length-1)},t.Range.prototype.extendByWords=function(t){var n,o,i;if(o=this.cloneRange(),this.includeWholeWords(),0===t)return!0;if(t>0){if(!this.equals(o))return this.extendByWords(t-1);for(i=!1;0===this.toString().length||!i||e(this.lastCharacter());){if(n=this.cloneRange(),!this.extendByCharacters(1))return i&&1===t;e(this.lastCharacter())&&(i=!0)}return this.setStart(n.startContainer,n.startOffset),this.setEnd(n.endContainer,n.endOffset),this.extendByWords(t-1)}if(t<0){if(!this.equals(o))return this.extendByWords(t+1);for(i=!1;0===this.toString().length||!i||e(this.firstCharacter());){if(n=this.cloneRange(),!this.extendByCharacters(-1))return i&&-1===t;e(this.firstCharacter())&&(i=!0)}return this.setStart(n.startContainer,n.startOffset),this.setEnd(n.endContainer,n.endOffset),this.extendByWords(t+1)}return!1},t.Range.prototype.equals=function(t){return this.startContainer===t.startContainer&&this.endContainer===t.endContainer&&this.startOffset===t.startOffset&&this.endOffset===t.endOffset},t.Range.prototype.includeWholeWords=function(){for(var t;(0===this.toString().length||e(this.firstCharacter()))&&(t=this.cloneRange(),this.extendByCharacters(-1)););for(this.setStart(t.startContainer,t.startOffset),this.setEnd(t.endContainer,t.endOffset);(0===this.toString().length||e(this.lastCharacter()))&&(t=this.cloneRange(),this.extendByCharacters(1)););return this.setStart(t.startContainer,t.startOffset),this.setEnd(t.endContainer,t.endOffset)}},installDOMUtilitiesIn(window),CanvasRenderingContext2D.prototype.bezierArrow=function(t,e,n,o,i,r,s,l,a){var u,c,d,p,h;return null==a&&(a=10),h=function(t,e){var n;return n=Math.sqrt(t*t+e*e)||1,{x:t/n,y:e/n}},this.beginPath(),this.moveTo(t,e),this.bezierCurveTo(n,o,i,r,s,l),d={x:this.applyBezier(t,n,i,s,.9),y:this.applyBezier(e,o,r,l,.9)},p={x:s-d.x,y:l-d.y},c=h(p.x,p.y),c.x*=.7*a,c.y*=a,u={x:c.y,y:-c.x},this.moveTo(s-u.x-c.x,l-u.y-c.y),this.lineTo(s,l),this.lineTo(s+u.x-c.x,l+u.y-c.y)},CanvasRenderingContext2D.prototype.applyBezier=function(t,e,n,o,i){return Math.pow(1-i,3)*t+3*Math.pow(1-i,2)*i*e+3*(1-i)*Math.pow(i,2)*n+Math.pow(i,3)*o},CanvasRenderingContext2D.prototype.roundedRect=function(t,e,n,o,i){return this.beginPath(),this.moveTo(t+i,e),this.lineTo(n-i,e),this.arcTo(n,e,n,e+i,i),this.lineTo(n,o-i),this.arcTo(n,o,n-i,o,i),this.lineTo(t+i,o),this.arcTo(t,o,t,o-i,i),this.lineTo(t,e+i),this.arcTo(t,e,t+i,e,i),this.closePath()},CanvasRenderingContext2D.prototype.roundedZone=function(t,e,n,o,i,r,s,l,a){return this.beginPath(),this.moveTo(t+a,e),this.lineTo(this.canvas.width-l,e),this.lineTo(this.canvas.width-l,r),this.lineTo(n,r),this.lineTo(n,o-a),this.arcTo(n,o,n-a,o,a),this.lineTo(s,o),this.lineTo(s,i),this.lineTo(t,i),this.lineTo(t,e+a),this.arcTo(t,e,t+a,e,a),this.closePath()},window.rectanglesCollide=function(t,e,n,o,i,r,s,l){return!(i>=n||s<=t||r>=o||l<=e)},window.svgBlobForHTML=function(t,e){var n,o,i;return null==e&&(e="font-size:12px"),(o=document.createElement("span")).setAttribute("style",e),o.innerHTML=t,document.body.appendChild(o),o=$(o),i=o.width()+2,n=o.height()+2,o.remove(),window.makeBlob("
"+t+"
","image/svg+xml;charset=utf-8")},window.makeBlob=function(t,e){var n,o,i,r,s;try{return new Blob([t],{type:e})}catch(l){if(o=l,window.BlobBuilder=null!=(i=null!=(r=null!=(s=window.BlobBuilder)?s:window.WebKitBlobBuilder)?r:window.MozBlobBuilder)?i:window.MSBlobBuilder,"TypeError"===o.name&&null!=window.BlobBuilder)return(n=new BlobBuilder).append(t.buffer),n.getBlob(e);if("InvalidStateError"===o.name)return new Blob([t.buffer],{type:e})}},drawHTMLCache={order:[],maxSize:100},cacheLookup=function(t,e){var n;return n=JSON.stringify([t,e]),drawHTMLCache.hasOwnProperty(n)?drawHTMLCache[n]:null},addToCache=function(t,e,n){var o;return o=JSON.stringify([t,e]),drawHTMLCache[o]=n,markUsed(t,e)},markUsed=function(t,e){var n,o;return o=JSON.stringify([t,e]),(n=drawHTMLCache.order.indexOf(o))>-1&&drawHTMLCache.order.splice(n,1),drawHTMLCache.order.unshift(o),pruneCache()},pruneCache=function(){var t;for(t=[];drawHTMLCache.order.length>drawHTMLCache.maxSize;)t.push(delete drawHTMLCache[drawHTMLCache.order.pop()]);return t},CanvasRenderingContext2D.prototype.drawHTML=function(t,e,n,o){var i,r;return null==o&&(o="font-size:12px"),(i=cacheLookup(t,o))?(this.drawImage(i,e,n),markUsed(t,o),!0):(r=objectURLForBlob(svgBlobForHTML(t,o)),i=new Image,i.onload=function(){var e,n;return addToCache(t,o,i),(null!=(e=null!=(n=window.URL)?n:window.webkitURL)?e:window).revokeObjectURL(r)},i.onerror=function(e){return addToCache(t,o,new Image),console.log("Failed to load SVG with this div content:",t)},i.src=r,!1)},CanvasRenderingContext2D.prototype.measureHTML=function(t,e){var n;return null==e&&(e="font-size:12px"),(n=cacheLookup(t,e))?(markUsed(t,e),{width:n.width,height:n.height}):(this.drawHTML(t,0,0,e),null)},window.objectURLForBlob=function(t){var e,n;return(null!=(e=null!=(n=window.URL)?n:window.webkitURL)?e:window).createObjectURL(t)},window.base64URLForBlob=function(t,e){var n;return n=new FileReader,n.onload=function(t){return e(t.target.result)},n.readAsDataURL(t)},window.blobForBase64URL=function(t){var e,n,o,i,r,s,l;for(n=atob(t.split(",")[1]),s=t.split(",")[0].split(":")[1].split(";")[0],e=new ArrayBuffer(n.length),i=new Uint8Array(e),o=r=0,l=n.length;0<=l?rl;o=0<=l?++r:--r)i[o]=n.charCodeAt(o);return window.makeBlob(e,s)},Dependencies=function(){function t(t){this.editor=t,this.length=0}return t.prototype.getFileMetadata=function(t,e,n,o){var i;if(null!==e)return null===t&&(t="."),i=t.concat([e]),this.editor.Storage.directRead("browser storage",i,n,o)},t.prototype.import=function(t){var e,n,o,i,r;for(e=n=0,i=this.length;0<=i?ni;e=0<=i?++n:--n)delete this[e];for(e=o=0,r=this.length=t.length;0<=r?or;e=0<=r?++o:--o)this[e]=JSON.parse(JSON.stringify(t[e]));return this.update()},t.prototype.export=function(){var t,e,n,o;for(o=[],t=e=0,n=this.length;0<=n?en;t=0<=n?++e:--e)o.push(JSON.parse(JSON.stringify(this[t])));return o},t.prototype.update=function(t){var e,n,o,i,r,s;if(null==t)return function(){var t,e,n;for(n=[],i=t=0,e=this.length;0<=e?te;i=0<=e?++t:--t)n.push(this.update(i));return n}.call(this);if(t>=0&&ts))return n.editor.MediaWiki.getPageMetadata(r,function(e){if(null!=e&&JSON.stringify(n[t])!==JSON.stringify(e.exports))return n[t].data=e.exports,n[t].date=l,n.editor.fire("dependenciesChanged")})}}(this))):this.editor.fire("dependenciesChanged")},t.prototype.add=function(t,e){var n,o,i,r,s;if("file://"===t.slice(0,7)){s=t.lastIndexOf("/"),o=t.slice(s),i=t.slice(7,s);try{return this.getFileMetadata(i,o,function(n){return function(o){var i;return i=o.exports,n[n.length++]={address:t,data:i,date:new Date},"function"==typeof e&&e(i,null),n.editor.fire("dependenciesChanged")}}(this),function(t){return"function"==typeof e?e(null,t):void 0})}catch(t){return n=t,"function"==typeof e?e(null,n):void 0}}else if("wiki://"===t.slice(0,7))return r=t.slice(7),this.editor.MediaWiki.getPageTimestamp(r,function(n){return function(o,i){return null==o?"function"==typeof e?e(null,"Could not get wiki page timestamp"):void 0:n.editor.MediaWiki.getPageMetadata(r,function(i){return null==i?"function"==typeof e?e(null,"Could not access wiki page"):void 0:(n[n.length++]={address:t,data:i.exports,date:new Date(o)},"function"==typeof e&&e(i.exports,null),n.editor.fire("dependenciesChanged"))})}}(this))},t.prototype.remove=function(t){var e,n,o,i;if(t>=0&&ti;e=o<=i?++n:--n)this[e]=this[e+1];return delete this[--this.length],this.editor.fire("dependenciesChanged")}},t.prototype.installUI=function(t){var e,n,o,i,r,s,l,a,u,c;for(a=[],o=i=0,s=(u=this).length;i("+e+")()<\/script>","text/html;charset=utf-8"))},installClickListener=function(t){var e;return e=function(e){return t(e.data)},window.addEventListener("message",e,!1),tinymce.activeEditor.windowManager.getWindows()[0].on("close",function(){return window.removeEventListener("message",e)})},(Dialogs={}).alert=function(t){var e,n,o,i;if(e=tinymce.activeEditor.windowManager.open({title:null!=(n=t.title)?n:" ",url:prepareHTML(t.message),width:null!=(o=t.width)?o:400,height:null!=(i=t.height)?i:300,buttons:[{type:"button",text:"OK",subtype:"primary",onclick:function(n){return e.close(),"function"==typeof t.callback?t.callback(n):void 0}}]}),t.onclick)return installClickListener(t.onclick)},Dialogs.confirm=function(t){var e,n,o,i,r,s;if(e=tinymce.activeEditor.windowManager.open({title:null!=(n=t.title)?n:" ",url:prepareHTML(t.message),width:null!=(o=t.width)?o:400,height:null!=(i=t.height)?i:300,buttons:[{type:"button",text:null!=(r=t.Cancel)?r:"Cancel",subtype:"primary",onclick:function(n){return e.close(),"function"==typeof t.cancelCallback?t.cancelCallback(n):void 0}},{type:"button",text:null!=(s=t.OK)?s:"OK",subtype:"primary",onclick:function(n){return e.close(),"function"==typeof t.okCallback?t.okCallback(n):void 0}}]}),t.onclick)return installClickListener(t.onclick)},Dialogs.prompt=function(t){var e,n,o,i,r,s,l,a,u;return u=t.value?" value='"+t.value+"'":"",t.message+="

",n=null!=(o=t.value)?o:"",e=tinymce.activeEditor.windowManager.open({title:null!=(i=t.title)?i:" ",url:prepareHTML(t.message),width:null!=(r=t.width)?r:300,height:null!=(s=t.height)?s:200,buttons:[{type:"button",text:null!=(l=t.Cancel)?l:"Cancel",subtype:"primary",onclick:function(o){return e.close(),"function"==typeof t.cancelCallback?t.cancelCallback(n):void 0}},{type:"button",text:null!=(a=t.OK)?a:"OK",subtype:"primary",onclick:function(o){return e.close(),"function"==typeof t.okCallback?t.okCallback(n):void 0}}]}),installClickListener(function(t){if("promptInput"===t.id)return n=t.value})},Dialogs.promptForFile=function(t){var e,n,o,i,r,s,l,a;return a=t.value?" value='"+t.value+"'":"",t.types?" accept='"+t.types+"'":"",t.message+="

",n=null,e=tinymce.activeEditor.windowManager.open({title:null!=(o=t.title)?o:" ",url:prepareHTML(t.message),width:null!=(i=t.width)?i:400,height:null!=(r=t.height)?r:100,buttons:[{type:"button",text:null!=(s=t.Cancel)?s:"Cancel",subtype:"primary",onclick:function(n){return e.close(),"function"==typeof t.cancelCallback?t.cancelCallback():void 0}},{type:"button",text:null!=(l=t.OK)?l:"OK",subtype:"primary",onclick:function(o){return e.close(),"function"==typeof t.okCallback?t.okCallback(n):void 0}}]}),installClickListener(function(t){if("promptInput"===t.id)return n=t.value})},Dialogs.codeEditor=function(t){var e,n,o,i,r,s,l,a,u,c,d,p;return d=function(t){var e;return window.codeEditor=CodeMirror.fromTextArea(document.getElementById("editor"),{lineNumbers:!0,fullScreen:!0,autofocus:!0,theme:"base16-light",mode:t}),e=function(t){if("getEditorContents"===t.data)return parent.postMessage(window.codeEditor.getValue(),"*")},window.addEventListener("message",e,!1)},o=" \",\n 'text/html;charset=utf-8'\n\nThe second installs in the top-level window a listener for the events\nposted from the interior of the dialog. It then calls the given event\nhandler with the ID of the element clicked. It also makes sure that when\nthe dialog is closed, this event handler will be uninstalled\n\n installClickListener = ( handler ) ->\n innerHandler = ( event ) -> handler event.data\n window.addEventListener 'message', innerHandler, no\n tinymce.activeEditor.windowManager.getWindows()[0].on 'close', ->\n window.removeEventListener 'message', innerHandler\n\n## Alert box\n\nThis function shows a simple alert box, with a callback when the user\nclicks OK. The message can be text or HTML.\n\n Dialogs.alert = ( options ) ->\n dialog = tinymce.activeEditor.windowManager.open\n title : options.title ? ' '\n url : prepareHTML options.message\n width : options.width ? 400\n height : options.height ? 300\n buttons : [\n type : 'button'\n text : 'OK'\n subtype : 'primary'\n onclick : ( event ) ->\n dialog.close()\n options.callback? event\n ]\n if options.onclick then installClickListener options.onclick\n\n## Confirm dialog\n\nThis function is just like the alert box, but with two callbacks, one for OK\nand one for Cancel, named `okCallback` and `cancelCallback`, respectively.\nThe user can rename the OK and Cancel buttons by specfying strings in the\noptions object with the 'OK' and 'Cancel' keys.\n\n\n Dialogs.confirm = ( options ) ->\n dialog = tinymce.activeEditor.windowManager.open\n title : options.title ? ' '\n url : prepareHTML options.message\n width : options.width ? 400\n height : options.height ? 300\n buttons : [\n type : 'button'\n text : options.Cancel ? 'Cancel'\n subtype : 'primary'\n onclick : ( event ) ->\n dialog.close()\n options.cancelCallback? event\n ,\n type : 'button'\n text : options.OK ? 'OK'\n subtype : 'primary'\n onclick : ( event ) ->\n dialog.close()\n options.okCallback? event\n ]\n if options.onclick then installClickListener options.onclick\n\n## Prompt dialog\n\nThis function is just like the prompt dialog in JavaScript, but it uses two\ncallbacks instead of a return value. They are named `okCallback` and\n`cancelCallback`, as in [the confirm dialog](#confirm-dialog), but they\nreceive the text in the dialog's input as a parameter.\n\n Dialogs.prompt = ( options ) ->\n value = if options.value then \" value='#{options.value}'\" else ''\n options.message +=\n \"

\"\n lastValue = options.value ? ''\n dialog = tinymce.activeEditor.windowManager.open\n title : options.title ? ' '\n url : prepareHTML options.message\n width : options.width ? 300\n height : options.height ? 200\n buttons : [\n type : 'button'\n text : options.Cancel ? 'Cancel'\n subtype : 'primary'\n onclick : ( event ) ->\n dialog.close()\n options.cancelCallback? lastValue\n ,\n type : 'button'\n text : options.OK ? 'OK'\n subtype : 'primary'\n onclick : ( event ) ->\n dialog.close()\n options.okCallback? lastValue\n ]\n installClickListener ( data ) ->\n if data.id is 'promptInput' then lastValue = data.value\n\n## File upload dialog\n\nThis function allows the user to choose a file from their local machine to\nupload. They can do so with a \"choose\" button or by dragging the file into\nthe dialog. The dialog then calls its `okCallback` with the contents of the\nuploaded file, in the format of a data URL, or calls its `cancelCallback`\nwith no parameter.\n\n Dialogs.promptForFile = ( options ) ->\n value = if options.value then \" value='#{options.value}'\" else ''\n types = if options.types then \" accept='#{options.types}'\" else ''\n options.message +=\n \"

\"\n lastValue = null\n dialog = tinymce.activeEditor.windowManager.open\n title : options.title ? ' '\n url : prepareHTML options.message\n width : options.width ? 400\n height : options.height ? 100\n buttons : [\n type : 'button'\n text : options.Cancel ? 'Cancel'\n subtype : 'primary'\n onclick : ( event ) ->\n dialog.close()\n options.cancelCallback?()\n ,\n type : 'button'\n text : options.OK ? 'OK'\n subtype : 'primary'\n onclick : ( event ) ->\n dialog.close()\n options.okCallback? lastValue\n ]\n installClickListener ( data ) ->\n if data.id is 'promptInput' then lastValue = data.value\n\n## Code editor dialog\n\n Dialogs.codeEditor = ( options ) ->\n setup = ( language ) ->\n window.codeEditor = CodeMirror.fromTextArea \\\n document.getElementById( 'editor' ),\n lineNumbers : yes\n fullScreen : yes\n autofocus : yes\n theme : 'base16-light'\n mode : language\n handler = ( event ) ->\n if event.data is 'getEditorContents'\n parent.postMessage window.codeEditor.getValue(), '*'\n window.addEventListener 'message', handler, no\n html = \"\n \n \n \n \n \n \n \n \n \n \"\n whichCallback = null\n dialog = tinymce.activeEditor.windowManager.open\n title : options.title ? 'Code editor'\n url : window.objectURLForBlob window.makeBlob html,\n 'text/html;charset=utf-8'\n width : options.width ? 700\n height : options.height ? 500\n buttons : [\n type : 'button'\n text : options.Cancel ? 'Discard'\n subtype : 'primary'\n onclick : ( event ) ->\n whichCallback = options.cancelCallback\n dialog.getContentWindow().postMessage \\\n 'getEditorContents', '*'\n ,\n type : 'button'\n text : options.OK ? 'Save'\n subtype : 'primary'\n onclick : ( event ) ->\n whichCallback = options.okCallback\n dialog.getContentWindow().postMessage \\\n 'getEditorContents', '*'\n ]\n handler = ( event ) ->\n dialog.close()\n whichCallback? event.data\n window.addEventListener 'message', handler, no\n dialog.on 'close', -> window.removeEventListener 'message', handler\n\n## Waiting dialog\n\nThis function shows a dialog with no buttons you can use for closing it. You\nshould pass as parameter an options object, just as with every other\nfunction in this plugin, but in this case it must contain a member called\n`work` that is a function that will do whatever work you want done while the\ndialog is shown. That function will *receive* as its one parameter a\nfunction to call when the work is done, to close this dialog.\n\nExample use:\n```javascript\ntinymce.activeEditor.Dialogs.waiting( {\n title : 'Loading file'\n message : 'Please wait...',\n work : function ( done ) {\n doLengthyAsynchronousTask( param1, param2, function ( result ) {\n saveMyResult( result );\n done();\n } );\n }\n} );\n```\n\n Dialogs.waiting = ( options ) ->\n dialog = tinymce.activeEditor.windowManager.open\n title : options.title ? ' '\n url : prepareHTML options.message\n width : options.width ? 300\n height : options.height ? 100\n buttons : [ ]\n if options.onclick then installClickListener options.onclick\n options.work -> dialog.close()\n\n# Installing the plugin\n\n tinymce.PluginManager.add 'dialogs', ( editor, url ) ->\n editor.Dialogs = Dialogs\n\n\n# Download/Upload Plugin for [TinyMCE](http://www.tinymce.com)\n\nThis plugin lets users download the contents of their current document as\nHTML, or upload any HTML file as new contents to overwrite the current\ndocument. It assumes that TinyMCE has been loaded into the global\nnamespace, so that it can access it.\n\nIf you have the [Storage Plugin](storageplugin.litcoffee) also enabled in\nthe same TinyMCE editor instance, it will make use of that plugin in\nseveral ways.\n\n * to ensure that editor contents are saved, if desired, before overwriting\n them with new, uploaded content\n * to determine the filename used for the download, when available\n * to embed metadata in the content before downloading, and extract metadata\n after uploading\n\n# `DownloadUpload` class\n\nWe begin by defining a class that will contain all the information needed\nregarding downloading and uploading HTML content. An instance of this class\nwill be stored as a member in the TinyMCE editor object.\n\nThis convention is adopted for all TinyMCE plugins in the Lurch project;\neach will come with a class, and an instance of that class will be stored as\na member of the editor object when the plugin is installed in that editor.\nThe presence of that member indicates that the plugin has been installed,\nand provides access to the full range of functionality that the plugin\ngrants to that editor.\n\n class DownloadUpload\n\n## Constructor\n\nAt construction time, we install in the editor the download and upload\nactions that can be added to the File menu and/or toolbar.\n\n constructor: ( @editor ) ->\n control = ( name, data ) =>\n buttonData =\n icon : data.icon\n shortcut : data.shortcut\n onclick : data.onclick\n tooltip : data.tooltip\n key = if data.icon? then 'icon' else 'text'\n buttonData[key] = data[key]\n @editor.addButton name, buttonData\n @editor.addMenuItem name, data\n control 'download',\n text : 'Download'\n icon : 'arrowdown2'\n context : 'file'\n tooltip : 'Download this document'\n onclick : => @downloadDocument()\n control 'upload',\n text : 'Upload'\n icon : 'arrowup2'\n context : 'file'\n tooltip : 'Upload new document'\n onclick : => @uploadDocument()\n\n## Event handlers\n\nThe following functions handle the two events that this class provides, the\ndownload event and the upload event.\n\nThe download event constructs a blob, fills it with the contents of the\neditor as HTML data, and starts a download. The only unique step in this\nprocess is that we attempt to get a filename from the\n[Storage Plugin](storageplugin.litcoffee), if one is available. If not,\nwe use \"untitled.html.\"\n\n downloadDocument: ->\n html = @editor.Storage.embedMetadata @editor.getContent(),\n @editor.Settings.document.metadata\n blob = new Blob [ html ], type : 'text/html'\n link = document.createElement 'a'\n link.setAttribute 'href', URL.createObjectURL blob\n link.setAttribute 'download',\n editor.Storage.filename or 'untitled.html'\n link.click()\n URL.revokeObjectURL link.getAttribute 'href'\n\nThe upload event first checks to be sure that the contents of the editor are\nsaved, or the user does not mind overwriting them. This code imitates the\nFile > New handler in the [Storage Plugin](storageplugin.litcoffee).\nThis function calls the `letUserUpload` function to do the actual uploading;\nthat function is defined further below in this file.\n\n uploadDocument: ->\n return @letUserUpload() unless editor.Storage.documentDirty\n @editor.windowManager.open {\n title : 'Save first?'\n buttons : [\n text : 'Save'\n onclick : =>\n editor.Storage.tryToSave ( success ) =>\n if success then @letUserUpload()\n @editor.windowManager.close()\n ,\n text : 'Discard'\n onclick : =>\n @editor.windowManager.close()\n @letUserUpload()\n ,\n text : 'Cancel'\n onclick : => @editor.windowManager.close()\n ]\n }\n\nThe following function handles the case where the user has agreed to save or\ndiscard the current contents of the editor, so they're ready to upload a new\nfile to overwrite it. We present here the user interface for doing so, and\nhandle the upload process.\n\n letUserUpload: ->\n @editor.Dialogs.promptForFile\n title : 'Choose file'\n message : 'Choose an HTML file to upload into the editor.'\n okCallback : ( fileAsDataURL ) =>\n html = atob fileAsDataURL.split( ',' )[1]\n { metadata, document } =\n @editor.Storage.extractMetadata html\n @editor.setContent document\n if metadata?\n @editor.Settings.document.metadata = metadata\n @editor.focus()\n\n# Installing the plugin\n\n tinymce.PluginManager.add 'downloadupload', ( editor, url ) ->\n editor.DownloadUpload = new DownloadUpload editor\n\n\n# Groups Plugin for [TinyMCE](http://www.tinymce.com)\n\nThis plugin adds the notion of \"groups\" to a TinyMCE editor. Groups are\ncontiguous sections of the document, often nested but not otherwise\noverlapping, that can be used for a wide variety of purposes. This plugin\nprovides the following functionality for working with groups in a document.\n * defines the `Group` and `Groups` classes\n * provides methods for installing UI elements for creating and interacting\n with groups in the document\n * shows groups visually on screen in a variety of ways\n * calls update routines whenever group contents change, so that they can be\n updated/processed\n\nIt assumes that TinyMCE has been loaded into the global namespace, so that\nit can access it. It also requires [the overlay\nplugin](overlayplugin.litcoffee) to be loaded in the same editor.\n\nAll changes made to the document by the user are tracked so that appropriate\nevents can be called in this plugin to update group objects.\n\n# Global functions\n\nThe following two global functions determine how we construct HTML to\nrepresent group boundaries (called \"groupers\") and how we decipher such HTML\nback into information about the groupers.\n\nFirst, how to create HTML representing a grouper. The parameters are as\nfollows: `typeName` is a string naming the type of the group, which must be\n[registered](#registering-group-types); `image` is the path to the image\nthat will be used to represent this grouper; `openClose` must be either the\nstring \"open\" or the string \"close\"; `id` is a nonnegative integer unique to\nthis group; `hide` is a boolean representing whether the grouper should be\ninvisible in the document.\n\n grouperHTML = ( typeName, openClose, id, hide = yes, image ) ->\n hide = if hide then ' hide' else ''\n image ?= \"images/red-bracket-#{openClose}.png\"\n \"\"\n window.grouperHTML = grouperHTML\n\nSecond, how to extract group information from a grouper. The two pieces of\ninformation that are most important to extract are whether the grouper is an\nopen grouper or close grouper, and what its ID is. This routine extracts\nboth and returns them in an object with the keys `type` and `id`. If the\ndata is not available in the expected format, it returns `null`.\n\n grouperInfo = ( grouper ) ->\n info = /^(open|close)([0-9]+)$/.exec grouper?.getAttribute? 'id'\n if not info then return null\n result = openOrClose : info[1], id : parseInt info[2]\n more = /^grouper ([^ ]+)/.exec grouper?.getAttribute? 'class'\n if more then result.type = more[1]\n result\n window.grouperInfo = grouperInfo\n\nA few functions in this module make use of a tool for computing the default\neditor style as a CSS style string (e.g., \"font-size:16px;\"). That function\nis defined here.\n\n createStyleString = ( styleObject = window.defaultEditorStyles ) ->\n result = [ ]\n for own key, value of styleObject\n newkey = ''\n for letter in key\n if letter.toUpperCase() is letter then newkey += '-'\n newkey += letter.toLowerCase()\n result.push \"#{newkey}:#{value};\"\n result.join ' '\n\nThe main function that uses the previous function is one for converting\nwell-formed HTML into an image URL.\n\n htmlToImage = ( html ) ->\n objectURLForBlob svgBlobForHTML html, createStyleString()\n\nA few functions in this module make use of a tool for computing a CSS style\nstring describing the default font size and family of an element. That\nfunction is defined here.\n\n createFontStyleString = ( element ) ->\n style = element.ownerDocument.defaultView.getComputedStyle element\n \"font-size:#{style.fontSize}; font-family:#{style.fontFamily};\"\n\n# `Group` class\n\nThis file defines two classes, this one called `Group` and another\n([below](#groups-class)) called `Groups`. They are obviously quite\nsimilarly named, but here is the distinction: An instance of the `Group`\nclass represents a single section of text within the document that the user\nhas \"grouped\" together. Thus each document may have zero or more such\ninstances. Each editor, however, gets only one instance of the `Groups`\nclass, which manages all the `Group` instances in that editor's document.\n\n## Group constructor\n\n class Group\n\nThe constructor takes as parameters the two DOM nodes that are its open and\nclose groupers (i.e., group boundary markers), respectively. It does not\nvalidate that these are indeed open and close grouper nodes, but just stores\nthem for later lookup.\n\nThe final parameter is an instance of the Groups class, which is the plugin\ndefined in this file. Thus each group will know in which environment it\nsits, and be able to communicate with that environment. If that parameter\nis not provided, the constructor will attempt to correctly detect it, but\nproviding the parameter is more efficient.\n\nWe call the contents changed event as soon as the group is created, because\nany newly-created group needs to have its contents processed for the first\ntime (assuming a processing routine exists, otherwise the call does\nnothing). We pass \"yes\" as the second parameter to indicate that this is\nthe first call ever to `contentsChanged`, and thus the group type may wish\nto do some initial setup.\n\n constructor: ( @open, @close, @plugin ) ->\n if not @plugin?\n for editor in tinymce.editors\n if editor.getDoc() is @open.ownerDocument\n @plugin = editor.Groups\n break\n @contentsChanged yes, yes\n\n## Core group data\n\nThis method returns the ID of the group, if it is available within the open\ngrouper.\n\n id: => grouperInfo( @open )?.id ? null\n\nThe first of the following methods returns the name of the type of the\ngroup, as a string. The second returns the type as an object, as long as\nthe type exists in the plugin stored in `@plugin`.\n\n typeName: => grouperInfo( @open )?.type\n type: => @plugin?.groupTypes?[@typeName()]\n\n## Group attributes\n\nWe provide the following four simple methods for getting and setting\narbitrary data within a group. Clients should use these methods rather than\nwrite to fields in a group instance itself, because these (a) guarantee no\ncollisions with existing properties/methods, and (b) mark that group (and\nthus the document) dirty, and ensure that changes to a group's data bring\nabout any recomputation/reprocessing of that group in the document.\n\nBecause we use HTML data attributes to store the data, the keys must be\nalphanumeric, optionally with dashes and/or underscores. Furthermore, the\ndata must be able to be amenable to JSON stringification.\n\nIMPORTANT: If you call `set()` in a group, the changes you make will NOT be\nstored on the TinyMCE undo/redo stack. If you want your changes stored on\nthat stack, you should make the changes inside a function passed to the\nTinyMCE Undo Manager's [transact](https://www.tinymce.com/docs/api/tinymce/tinymce.undomanager/#transact) method.\n\nYou may or may not wish to have your changes stored on the undo/redo stack.\nIn general, if the change you're making to the group is in direct and\nimmediate response to the user's actions, then it should be on the undo/redo\nstack, so that the user can change their mind. However, if the change is\nthe result of a background computation, which was therefore not in direct\nresponse to one of the user's actions, they will probably not expect to be\nable to undo it, and thus you should not place the change on the undo/redo\nstack.\n\n set: ( key, value ) =>\n if not /^[a-zA-Z0-9-_]+$/.test key then return\n toStore = JSON.stringify [ value ]\n if @open.getAttribute( \"data-#{key}\" ) isnt toStore\n @open.setAttribute \"data-#{key}\", toStore\n if @plugin?\n @plugin.editor.fire 'change'\n @plugin.editor.isNotDirty = no\n @contentsChanged()\n if key is 'openDecoration' or key is 'closeDecoration'\n @updateGrouper key[...-10]\n if key is 'openHoverText' or key is 'closeHoverText'\n grouper = @[key[...-9]]\n for attr in [ 'title', 'alt' ] # browser differences\n grouper.setAttribute attr, \"#{value}\"\n get: ( key ) =>\n try\n JSON.parse( @open.getAttribute \"data-#{key}\" )[0]\n catch e\n undefined\n keys: => Object.keys @open.dataset\n clear: ( key ) =>\n if not /^[a-zA-Z0-9-_]+$/.test key then return\n if @open.getAttribute( \"data-#{key}\" )?\n @open.removeAttribute \"data-#{key}\"\n if @plugin?\n @plugin.editor.fire 'change'\n @plugin.editor.isNotDirty = no\n @contentsChanged()\n if key is 'openDecoration' or key is 'closeDecoration'\n @updateGrouper key[...-10]\n if key is 'openHoverText' or key is 'closeHoverText'\n grouper = @[key[...-9]]\n for attr in [ 'title', 'alt' ] # browser differences\n grouper.removeAttribute attr\n\nThe `set` and `clear` functions above call an update routine if the\nattribute changed was the decoration data for a grouper. This update\nroutine recomputes the appearance of that grouper as an image, and stores it\nin the `src` attribute of the grouper itself (which is an `img` element).\nWe implement that routine here.\n\nThis routine is also called from `hideOrShowGroupers`, defined later in this\nfile. It can accept any of three parameter types, the string \"open\", the\nstring \"close\", or an actual grouper element from the document that is\neither the open or close grouper for this group.\n\n updateGrouper: ( openOrClose ) =>\n if openOrClose is @open then openOrClose = 'open'\n if openOrClose is @close then openOrClose = 'close'\n if openOrClose isnt 'open' and openOrClose isnt 'close'\n return\n jquery = $ grouper = @[openOrClose]\n if ( decoration = @get \"#{openOrClose}Decoration\" )?\n jquery.addClass 'decorate'\n else\n jquery.removeClass 'decorate'\n decoration = ''\n html = if jquery.hasClass 'hide' then '' else \\\n @type()?[\"#{openOrClose}ImageHTML\"]\n if openOrClose is 'open'\n html = decoration + html\n else\n html += decoration\n window.base64URLForBlob window.svgBlobForHTML( html,\n createFontStyleString grouper ), ( base64 ) =>\n if grouper.getAttribute( 'src' ) isnt base64\n grouper.setAttribute 'src', base64\n @plugin?.editor.Overlay?.redrawContents()\n\n## Group contents\n\nWe will need to be able to query the contents of a group, so that later\ncomputations on that group can use its contents to determine how to act. We\nprovide functions for fetching the contents of the group as plain text, as\nan HTML `DocumentFragment` object, or as an HTML string.\n\n contentAsText: => @innerRange()?.toString()\n contentAsFragment: => @innerRange()?.cloneContents()\n contentAsHTML: =>\n if not fragment = @contentAsFragment() then return null\n tmp = @open.ownerDocument.createElement 'div'\n tmp.appendChild fragment\n tmp.innerHTML\n\nYou can also fetch the exact sequence of Nodes between the two groupers\n(including only the highest-level ones, not their children when that would\nbe redundant) using the following routine.\n\n contentNodes: =>\n result = [ ]\n walk = @open\n while walk?\n if strictNodeOrder walk, @close\n if strictNodeOrder @open, walk then result.push walk\n if walk.nextSibling? then walk = walk.nextSibling \\\n else walk = walk.parentNode\n continue\n if strictNodeOrder @close, walk\n console.log 'Warning!! walked past @close...something\n is wrong with this loop'\n break\n if walk is @close then break else walk = walk.childNodes[0]\n result\n\nWe can also set the contents of a group with the following function. This\nfunction can only work if `@plugin` is a `Groups` class instance. This\nactually accepts not only plain text, but HTML as well.\n\n setContentAsText: ( text ) =>\n if not inside = @innerRange() then return\n @plugin?.editor.selection.setRng inside\n @plugin?.editor.selection.setContent text\n\n## Group ranges\n\nThe above functions rely on the `innerRange()` function, defined below, with\na corresponding `outerRange` function for the sake of completeness. We use\na `try`/`catch` block because it's possible that the group has been removed\nfrom the document, and thus we can no longer set range start and end points\nrelative to the group's open and close groupers.\n\n innerRange: =>\n range = @open.ownerDocument.createRange()\n try\n range.setStartAfter @open\n range.setEndBefore @close\n range\n catch e then null\n outerRange: =>\n range = @open.ownerDocument.createRange()\n try\n range.setStartBefore @open\n range.setEndAfter @close\n range\n catch e then null\n\nWe then create analogous functions for creating ranges that include the text\nbefore or after the group. These ranges extend to the next grouper in the\ngiven direction, whether it be an open or close grouper of any type.\nSpecifically,\n * The `rangeBefore` range always ends immediately before this group's open\n grouper, and\n * if this group is the first in its parent, the range begins immediately\n after the parent's open grouper;\n * otherwise it begins immediately after its previous sibling's close\n grouper.\n * But if this is the first top-level group in the document, then the\n range begins at the start of the document.\n * The `rangeAfter` range always begins immediately after this group's close\n grouper, and\n * if this group is the last in its parent, the range ends immediately\n before the parent's close grouper;\n * otherwise it ends immediately before its next sibling's open grouper.\n * But if this is the last top-level group in the document, then the\n range ends at the end of the document.\n\n rangeBefore: =>\n range = ( doc = @open.ownerDocument ).createRange()\n try\n range.setEndBefore @open\n if prev = @previousSibling()\n range.setStartAfter prev.close\n else if @parent\n range.setStartAfter @parent.open\n else\n range.setStartBefore doc.body.childNodes[0]\n range\n catch e then null\n rangeAfter: =>\n range = ( doc = @open.ownerDocument ).createRange()\n try\n range.setStartAfter @close\n if next = @nextSibling()\n range.setEndBefore next.open\n else if @parent\n range.setEndBefore @parent.close\n else\n range.setEndAfter \\\n doc.body.childNodes[doc.body.childNodes.length-1]\n range\n catch e then null\n\n## Working with whole groups\n\nYou can remove an entire group from the document using the following method.\nIt does two things: First, it disconnects this group from any group to\nwhich it's connected. Second, relying on the `contentNodes` member above,\nit removes all the nodes returned by that member.\n\nThis function requires that the `@plugin` member exists, or it does nothing.\nIt also tells the TinyMCE instance that this should all be considered part\nof one action for the purposes of undo/redo.\n\n remove: =>\n if not @plugin then return\n @disconnect @plugin[cxn[0]] for cxn in @connectionsIn()\n @disconnect @plugin[cxn[1]] for cxn in @connectionsOut()\n @plugin.editor.undoManager.transact =>\n ( $ [ @open, @contentNodes()..., @close ] ).remove()\n\nWhen a group has been removed from a document in a different way than the\nabove function (such as replacing its entire content and boundaries with\nother text in the editor) the group object may persist in JavaScript memory,\nand we would like a way to detect whether a group is \"stale\" in such a way.\nThe following function does so. Note that it always returns false if the\ngroup does not have a plugin registered.\n\n stillInEditor: =>\n walk = @open\n while @plugin? and walk?\n if walk is @plugin.editor.getDoc() then return yes\n walk = walk.parentNode\n no\n\nSometimes you want the HTML representation of the entire group. The\nfollowing method gives it to you, by imitating the code of `contentAsHTML`,\nexcept using `outerRange` rather than `innerRange`.\n\nThe optional parameter, if set to false, will omit the `src` attributes on\nall groupers (the two for this group, as well as each pair for every inner\ngroup as well). This can be useful because those `src` attributes can be\nrecomputed from the other grouper data, and they are enormous, so omitting\nthem saves significant space.\n\n groupAsHTML: ( withSrcAttributes = yes ) =>\n if not fragment = @outerRange()?.cloneContents()\n return null\n tmp = @open.ownerDocument.createElement 'div'\n tmp.appendChild fragment\n if not withSrcAttributes\n ( $ tmp ).find( '.grouper' ).removeAttr 'src'\n tmp.innerHTML\n\n## Group hierarchy\n\nThe previous two functions require being able to query this group's index in\nits parent group, and to use that index to look up next and previous sibling\ngroups. We provide those functions here.\n\n indexInParent: =>\n ( @parent?.children ? @plugin?.topLevel )?.indexOf this\n previousSibling: =>\n ( @parent?.children ? @plugin?.topLevel )?[@indexInParent()-1]\n nextSibling: =>\n ( @parent?.children ? @plugin?.topLevel )?[@indexInParent()+1]\n\nNote that the `@children` array for a group is constructed by the\n`scanDocument` function of the `Groups` class, defined [below](#scanning).\nThus one can get an array of child groups for any group `G` by writing\n`G.children`.\n\n## Group change event\n\nThe following function should be called whenever the contents of the group\nhave changed. It notifies the group's type, so that the requisite\nprocessing, if any, of the new contents can take place. It is called\nautomatically by some handlers in the `Groups` class, below.\n\nBy default, it propagates the change event up the ancestor chain in the\ngroup hierarchy, but that can be disabled by passing false as the parameter.\n\nThe second parameter indicates whether this is the first `contentsChanged`\ncall since the group was constructed. By default, this is false, but is set\nto true from the one call made to this function from the group's\nconstructor.\n\n contentsChanged: ( propagate = yes, firstTime = no ) =>\n @type()?.contentsChanged? this, firstTime\n if propagate then @parent?.contentsChanged yes\n\n## Group serialization\n\nThe following serialization routine is useful for sending groups to a Web\nWorker for background processing.\n\n toJSON: =>\n data = { }\n for attr in @open.attributes\n if attr.nodeName[..5] is 'data-' and \\\n attr.nodeName[..9] isnt 'data-mce-'\n try\n data[attr.nodeName] =\n JSON.parse( attr.nodeValue )[0]\n id : @id()\n typeName : @typeName()\n deleted : @deleted\n text : @contentAsText()\n html : @contentAsHTML()\n parent : @parent?.id() ? null\n children : ( child?.id() ? null for child in @children ? [ ] )\n data : data\n\n## Group connections (\"arrows\")\n\nGroups can be connected in a graph. The graph is directed, and there can be\nmultiple arrows from one group to another. Each arrow has an optional\nstring attribute attached to it called its \"tag,\" which defaults to the\nempty string. For multiple arrows between the same two groups, different\ntags are required.\n\nIMPORTANT: Connections among groups are not added to the undo/redo stack (by\ndefault). Many apps do want them on the undo/redo stack, and you can\nachieve this by following the same directions given under `get` and `set`,\nusing the TinyMCE Undo Manager's [transact](https://www.tinymce.com/docs/api/tinymce/tinymce.undomanager/#transact) method.\n\nConnect group `A` to group `B` by calling `A.connect B`. The optional\nsecond parameter is the tag string to attach. It defaults to the empty\nstring. Calling this more than once with the same `A`, `B`, and tag has the\nsame effect as calling it once.\n\n connect: ( toGroup, tag = '' ) =>\n connection = [ @id(), toGroup.id(), \"#{tag}\" ]\n connstring = \"#{connection}\"\n oldConnections = @get( 'connections' ) ? [ ]\n mustAdd = yes\n for oldConnection in oldConnections\n if \"#{oldConnection}\" is connstring\n mustAdd = no\n break\n if mustAdd\n @set 'connections', [ oldConnections..., connection ]\n oldConnections = toGroup.get( 'connections' ) ? [ ]\n mustAdd = yes\n for oldConnection in oldConnections\n if \"#{oldConnection}\" is connstring\n mustAdd = no\n break\n if mustAdd\n toGroup.set 'connections', [ oldConnections..., connection ]\n\nThe following function undoes the previous. The third parameter can be\neither a string or a regular expression. It defaults to the empty string.\nCalling `A.disconnect B, C` finds all connections from `A` to `B` satisfying\na condition on `C`. If `C` is a string, then the connection tag must equal\n`C`; if `C` is a regular expression, then the connection tag must match `C`.\nConnections not satisfying these criterion are not candidates for deletion.\n\n disconnect: ( fromGroup, tag = '' ) =>\n matches = ( array ) =>\n array[0] is @id() and array[1] is fromGroup.id() and \\\n ( tag is array[2] or tag.test? array[2] )\n @set 'connections', ( c for c in @get( 'connections' ) ? [ ] \\\n when not matches c )\n fromGroup.set 'connections', ( c for c in \\\n fromGroup.get( 'connections' ) ? [ ] when not matches c )\n\nFor looking up connections, we have two functions. One that returns all the\nconnections that lead out from the group in question (`connectionsOut()`)\nand one that returns all connections that lead into the group in question\n(`connectionsIn()`). Each function returns an array of triples, all those\nthat appear in the group's connections set and have the group as the source\n(for `connectionsOut()`) or the destination (for `connectionsIn()`).\n\n connectionsOut: =>\n id = @id()\n ( c for c in ( @get 'connections' ) ? [ ] when c[0] is id )\n connectionsIn: =>\n id = @id()\n ( c for c in ( @get 'connections' ) ? [ ] when c[1] is id )\n\n## Group screen coordinates\n\nThe following function gives the sizes and positions of the open and close\ngroupers. Because the elements between them may be taller (or sink lower)\nthan the groupers themselves, we also inspect the client rectangles of all\nelements in the group, and adjust the relevant corners of the open and close\ngroupers outward to make sure the bubble encloses the entire contents of the\ngroup.\n\n getScreenBoundaries: =>\n\nThe first few lines here redundantly add rects for the open and close\ngroupers because there seems to be a bug in `getClientRects()` for a range\nthat doesn't always include the close grouper. If for some reason there are\nno rectangles, we cannot return a value. This would be a very erroneous\nsituation, but is here as paranoia.\n\n toArray = ( a ) ->\n if a? then ( a[i] for i in [0...a.length] ) else [ ]\n rects = toArray @open.getClientRects()\n .concat toArray @outerRange()?.getClientRects()\n .concat toArray @close.getClientRects()\n if rects.length is 0 then return null\n\nInitialize the rectangle data for the open and close groupers.\n\n open = rects[0]\n open =\n top : open.top\n left : open.left\n right : open.right\n bottom : open.bottom\n close = rects[rects.length-1]\n close =\n top : close.top\n left : close.left\n right : close.right\n bottom : close.bottom\n\nCompute whether the open and close groupers are in the same line of text.\nThis is done by examining whether they extend too far left/right/up/down\ncompared to one another. If they are on the same line, then force their top\nand bottom coordinates to match, to make it clear (to the caller) that this\nrepresents a rectangle, not a \"zone.\"\n\n onSameLine = yes\n for rect, index in rects\n open.top = Math.min open.top, rect.top\n close.bottom = Math.max close.bottom, rect.bottom\n if rect.left < open.left then onSameLine = no\n if rect.top > open.bottom then onSameLine = no\n if onSameLine\n close.top = open.top\n open.bottom = close.bottom\n\nIf either the open or close grouper has zero size, then an image file (for\nan open/close grouper) isn't yet loaded. Thus we need to return null, to\ntell the caller that the results couldn't be computed. The caller should\nprobably just set up a brief timer to recall this function again soon, when\nthe browser has completed the image loading.\n\n if ( open.top is open.bottom or close.top is close.bottom or \\\n open.left is open.right or close.left is close.right ) \\\n and not ( $ @open ).hasClass 'hide' then return null\n\nOtherwise, return the results as an object.\n\n open : open\n close : close\n\nThe `Group` class should be accessible globally.\n\n window.Group = Group\n\n# `Groups` class\n\nWe then define a class that will encapsulate all the functionality about\ngroups in the editor. An instance of this class will be stored as a member\nin the TinyMCE editor object. It will keep track of many instances of the\n`Group` class.\n\nThis convention is adopted for all TinyMCE plugins in the Lurch project;\neach will come with a class, and an instance of that class will be stored as\na member of the editor object when the plugin is installed in that editor.\nThe presence of that member indicates that the plugin has been installed,\nand provides access to the full range of functionality that the plugin\ngrants to that editor.\n\nThis particular plugin defines two classes, `Group` and `Groups`. The differences are spelled out here:\n * Only one instance of the `Groups` class exists for any given editor.\n That instance manages global functionality about groups for that editor.\n Some of its methods create instances of the `Group` class.\n * Zero or more instances of the `Group` class exist for any given editor.\n Each instance corresponds to a single group in the document in that\n editor.\n\nIf there were only one editor, this could be changing by making all instance\nmethods of the `Groups` class into class methods of the `Group` class. But\nsince there can be more than one editor, we need separate instances of that\n\"global\" context for each, so we use a `Groups` class to do so.\n\n## Groups constructor\n\n class Groups\n\n constructor: ( @editor ) ->\n\nEach editor has a mapping from valid group type names to their attributes.\n\n @groupTypes = {}\n\nIt also has a list of the top-level groups in the editor, which is a forest\nin which each node is a group, and groups are nested as hierarchies/trees.\n\n @topLevel = [ ]\n\nThe object maintains a list of unique integer ids for assigning to Groups in\nthe editor. The list `@freeIds` is a list `[a_1,...,a_n]` such that an id\nis available if and only if it's one of the `a_i` or is greater than `a_n`.\nFor this reason, the list begins as `[ 0 ]`.\n\n @freeIds = [ 0 ]\n\nInstall in the Overlay plugin for the same editor object a handler that\ndraws the groups surrounding the user's cursor.\n\n @editor.Overlay.addDrawHandler @drawGroups\n\nWhen a free id is needed, we need a function that will give the next such\nfree id and then mark that id as consumed from the list.\n\n nextFreeId: =>\n if @freeIds.length > 1 then @freeIds.shift() else @freeIds[0]++\n\nWhen an id in use becomes free, we need a function that will put it back\ninto the list of free ids.\n\n addFreeId: ( id ) =>\n if id < @freeIds[@freeIds.length-1]\n @freeIds.push id\n @freeIds.sort ( a, b ) -> a - b\n\nWe can also check to see if an id is free.\n\n isIdFree: ( id ) => id in @freeIds or id > @freeIds[@freeIds.length]\n\nWhen a free id becomes used in some way other than through a call to\n`nextFreeId`, we will want to be able to record that fact. The following\nfunction does so.\n\n setUsedID: ( id ) =>\n last = @freeIds[@freeIds.length-1]\n while last < id then @freeIds.push ++last\n i = @freeIds.indexOf id\n @freeIds.splice i, 1\n if i is @freeIds.length then @freeIds.push id + 1\n\n## Registering group types\n\nTo register a new type of group, simply provide its name, as a text string,\ntogether with an object of attributes.\n\nThe name string should only contain alphabetic characters, a through z, case\nsensitive, hyphens, or underscores. All other characters are removed. Empty\nnames are not allowed, which includes names that become empty when all\nillegal characters have been removed.\n\nRe-registering the same name with a new data object will overwrite the old\ndata object with the new one. Data objects may have the following key-value\npairs.\n * key: `openImage`, value: a string pointing to the image file to use when\n the open grouper is visible, defaults to `'images/red-bracket-open.png'`\n * If instead you provide the `openImageHTML` tag, an image will be created\n for you by rendering the HTML you provide, and you need not provide an\n `openImage` key-value pair.\n * key: `closeImage`, complement to the previous, defaults to\n `'images/red-bracket-close.png'`\n * Similarly, `closeImageHTML` functions like `openImageHTML`.\n * any key-value pairs useful for placing the group into a menu or toolbar,\n such as the keys `text`, `context`, `tooltip`, `shortcut`, `image`,\n and/or `icon`\n\nClients don't actually need to call this function. In their call to their\neditor's `init` function, they can include in the large, single object\nparameter a key-value pair with key `groupTypes` and value an array of\nobjects. Each should have the key `name` and all the other data that this\nroutine needs, and they will be passed along directly.\n\n addGroupType: ( name, data = {} ) =>\n name = ( n for n in name when /[a-zA-Z_-]/.test n ).join ''\n @groupTypes[name] = data\n if data.hasOwnProperty 'text'\n plugin = this\n if data.imageHTML?\n data.image = htmlToImage data.imageHTML\n if data.openImageHTML?\n blob = svgBlobForHTML data.openImageHTML,\n createStyleString()\n data.openImage = objectURLForBlob blob\n base64URLForBlob blob, ( result ) ->\n data.openImage = result\n if data.closeImageHTML?\n blob = svgBlobForHTML data.closeImageHTML,\n createStyleString()\n data.closeImage = objectURLForBlob blob\n base64URLForBlob blob, ( result ) ->\n data.closeImage = result\n menuData =\n text : data.text\n context : data.context ? 'Insert'\n onclick : => @groupCurrentSelection name\n onPostRender : -> # must use -> here to access \"this\":\n plugin.groupTypes[name].menuItem = this\n plugin.updateButtonsAndMenuItems()\n if data.shortcut? then menuData.shortcut = data.shortcut\n if data.icon? then menuData.icon = data.icon\n @editor.addMenuItem name, menuData\n buttonData =\n tooltip : data.tooltip\n onclick : => @groupCurrentSelection name\n onPostRender : -> # must use -> here to access \"this\":\n plugin.groupTypes[name].button = this\n plugin.updateButtonsAndMenuItems()\n key = if data.image? then 'image' else \\\n if data.icon? then 'icon' else 'text'\n buttonData[key] = data[key]\n @editor.addButton name, buttonData\n data.connections ?= ( group ) ->\n triples = group.connectionsOut()\n [ triples..., ( t[1] for t in triples )... ]\n\nThe above function calls `updateButtonsAndMenuItems()` whenever a new button\nor menu item is first drawn. That function is also called whenever the\ncursor in the document moves or the groups are rescanned. It enables or\ndisables the group-insertion routines based on whether the selection should\nbe allowed to be wrapped in a group. This is determined based on whether\nthe two ends of the selection are inside the same deepest group.\n\n updateButtonsAndMenuItems: =>\n left = @editor?.selection?.getRng()?.cloneRange()\n if not left then return\n right = left.cloneRange()\n left.collapse yes\n right.collapse no\n left = @groupAboveCursor left\n right = @groupAboveCursor right\n for own name, type of @groupTypes\n type?.button?.disabled left isnt right\n type?.menuItem?.disabled left isnt right\n @connectionsButton?.disabled not left? or ( left isnt right )\n @updateConnectionsMode()\n\nThe above function calls `updateConnectionsMode()`, which checks to see if\nconnections mode has been entered/exited since the last time the function\nwas run, and if so, updates the UI to reflect the change.\n\n updateConnectionsMode: =>\n if @connectionsButton?.disabled()\n @connectionsButton?.active no\n\n## Inserting new groups\n\nThe following method will wrap the current selection in the current editor\nin groupers (i.e., group endpoints) of the given type. The type must be on\nthe list of valid types registered with `addGroupType`, above, or this will\ndo nothing.\n\n groupCurrentSelection: ( type ) =>\n\nIgnore attempts to insert invalid group types.\n\n if not @groupTypes.hasOwnProperty type then return\n\nDetermine whether existing groupers are hidden or not, so that we insert the\nnew ones to match.\n\n hide = ( $ @allGroupers()?[0] ).hasClass 'hide'\n\nCreate data to be used for open and close groupers, a cursor placeholder,\nand the current contents of the cursor selection.\n\n id = @nextFreeId()\n open = grouperHTML type, 'open', id, hide,\n @groupTypes[type].openImage\n close = grouperHTML type, 'close', id, hide,\n @groupTypes[type].closeImage\n\nWrap the current cursor selection in open/close groupers, with the cursor\nplaceholder after the old selection.\n\n sel = @editor.selection\n if sel.getStart() is sel.getEnd()\n\nIf the whole selection is within one element, then we can just replace the\nselection's content with wrapped content, plus a cursor placeholder that we\nimmediately remove after placing the cursor back there. We also keep track\nof the close grouper element so that we can place the cursor immediately to\nits left after removing the cursor placeholder (or else the cursor may leap\nto the start of the document).\n\n content = @editor.selection.getContent()\n @editor.insertContent open + content + '{$caret}' + close\n cursor = @editor.selection.getRng()\n close = cursor.endContainer.childNodes[cursor.endOffset] ?\n cursor.endContainer.nextSibling\n if close.tagName is 'P' then close = close.childNodes[0]\n newGroup = @grouperToGroup close\n newGroup.parent?.contentsChanged()\n else\n\nBut if the selection spans multiple elements, then we must handle each edge\nof the selection separately. We cannot use this solution in general,\nbecause editing an element messes up cursor bookmarks within that element.\n\n range = sel.getRng()\n leftNode = range.startContainer\n leftPos = range.startOffset\n rightNode = range.endContainer\n rightPos = range.endOffset\n range.collapse no\n sel.setRng range\n @disableScanning()\n @editor.insertContent '{$caret}' + close\n range = sel.getRng()\n close = range.endContainer.childNodes[range.endOffset] ?\n range.endContainer.nextSibling\n range.setStart leftNode, leftPos\n range.setEnd leftNode, leftPos\n sel.setRng range\n @editor.insertContent open\n @enableScanning()\n @editor.selection.select close\n @editor.selection.collapse yes\n newGroup = @grouperToGroup close\n newGroup.parent?.contentsChanged()\n newGroup\n\n## Hiding and showing \"groupers\"\n\nThe word \"grouper\" refers to the objects that form the boundaries of a\ngroup, and thus define the group's extent. Each is an image with specific\nclasses that define its partner, type, visibility, etc. The following\nmethod applies or removes the visibility flag to all groupers at once, thus\ntoggling their visibility in the document.\n\n allGroupers: => @editor.getDoc().getElementsByClassName 'grouper'\n hideOrShowGroupers: =>\n groupers = $ @allGroupers()\n if ( $ groupers?[0] ).hasClass 'hide'\n groupers.removeClass 'hide'\n else\n groupers.addClass 'hide'\n groupers.filter( '.decorate' ).each ( index, grouper ) =>\n @grouperToGroup( grouper ).updateGrouper grouper\n @editor.Overlay?.redrawContents()\n @editor.focus()\n\n## Scanning\n\nScanning is the process of reading the entire document and observing where\ngroupers lie. This has several purposes.\n * It verifyies that groups are well-formed (i.e., no unpaired groupers, no\n half-nesting).\n * It ensures the list of `@freeIds` is up-to-date.\n * It maintains an in-memory hierarchy of Group objects (to be implemented).\n\nThere are times when we need programmatically to make several edits to the\ndocument, and want them to happen as a single unit, without the\n`scanDocument` function altering the document's structure admist the work.\nDocument scanning can be disabled by adding a scan lock. Do so with the\nfollowing two convenience functions.\n\n disableScanning: => @scanLocks = ( @scanLocks ?= 0 ) + 1\n enableScanning: =>\n @scanLocks = Math.max ( @scanLocks ? 0 ) - 1, 0\n if @scanLocks is 0 then @scanDocument()\n\nWe also want to track when scanning is happening, so that `scanDocument`\ncannot get into infinitely deep recursion by triggering a change in the\ndocument, which in turn calls `scanDocument` again. We track whether a scan\nis running using this flag. (Note that the scanning routine constructs new\n`Group` objects, which call `contentsChanged` handlers, which let clients\nexecute arbitrary code, so the infinite loop is quite possible, and thus\nmust be prevented.)\n\n isScanning = no\n\nNow the routine itself.\n\n scanDocument: =>\n\nIf scanning is disabled, do nothing. If it's already happening, then\nwhatever change is attempting to get us to scan again should just have the\nnew scan start *after* this one completes, not during.\n\n if @scanLocks > 0 then return\n if isScanning then return setTimeout ( => @scanDocument ), 0\n isScanning = yes\n\nGroup ids should be unique, so if we encounter the same one twice, we have a\nproblem. Thus we now mark all old groups as \"old,\" so that we can tell when\nthe first time we re-register them is, and avoid re-regestering the same\ngroup (with the same id) a second time.\n\n for id in @ids()\n if @[id]? then @[id].old = yes\n\nInitialize local variables:\n\n groupers = Array::slice.apply @allGroupers()\n gpStack = [ ]\n usedIds = [ ]\n @topLevel = [ ]\n @idConversionMap = { }\n before = @freeIds[..]\n index = ( id ) ->\n for gp, i in gpStack\n if gp.id is id then return i\n -1\n\nScanning processes each grouper in the document.\n\n for grouper in groupers\n\nIf it had the grouper class but wasn't really a grouper, delete it.\n\n if not ( info = grouperInfo grouper )?\n ( $ grouper ).remove()\n\nIf it's an open grouper, push it onto the stack of nested ids we're\ntracking.\n\n else if info.openOrClose is 'open'\n gpStack.unshift\n id : info.id\n grouper : grouper\n children : [ ]\n\nOtherwise, it's a close grouper. If it doesn't have a corresponding open\ngrouper that we've already seen, delete it.\n\n else\n if index( info.id ) is -1\n ( $ grouper ).remove()\n else\n\nIt has an open grouper. In case that open grouper wasn't the most recent\nthing we've seen, delete everything that's intervening, because they're\nincorrectly positioned.\n\n while gpStack[0].id isnt info.id\n ( $ gpStack.shift().grouper ).remove()\n\nThen allow the grouper and its partner to remain in the document, and pop\nthe stack, because we've moved past the interior of that group.\nFurthermore, register the group and its ID in this Groups object.\n\n groupData = gpStack.shift()\n id = @registerGroup groupData.grouper, grouper\n usedIds.push id\n newGroup = @[id]\n\nAssign parent and child relationships, and store this just-created group on\neither the list of children for the next parent outwards in the hierarchy,\nor the \"top level\" list if there is no surrounding group.\n\n newGroup.children = groupData.children\n for child in newGroup.children\n child.parent = newGroup\n if gpStack.length > 0\n gpStack[0].children.push newGroup\n else\n @topLevel.push newGroup\n newGroup.parent = null\n\nAny groupers lingering on the \"open\" stack have no corresponding close\ngroupers, and must therefore be deleted.\n\n while gpStack.length > 0\n ( $ gpStack.shift().grouper ).remove()\n\nNow update the `@freeIds` list to be the complement of the `usedIds` array.\n\n usedIds.sort ( a, b ) -> a - b\n count = 0\n @freeIds = [ ]\n while usedIds.length > 0\n if count is usedIds[0]\n while count is usedIds[0] then usedIds.shift()\n else\n @freeIds.push count\n count++\n @freeIds.push count\n\nAnd any ID that is free now but wasn't before must have its group deleted\nfrom this object's internal cache. After we delete all of them from the\ncache, we also call the group type's `deleted` method on each one, to permit\nfinalization code to run. We also mark each with a \"deleted\" attribute set\nto true, so that if there are any pending computations about that group,\nthey know not to bother actually modifying the group when they complete,\nbecause it is no longer in the document anyway.\n\n after = @freeIds[..]\n while before[before.length-1] < after[after.length-1]\n before.push before[before.length-1] + 1\n while after[after.length-1] < before[before.length-1]\n after.push after[after.length-1] + 1\n becameFree = ( a for a in after when a not in before )\n deleted = [ ]\n for id in becameFree\n deleted.push @[id]\n @[id]?.deleted = yes\n delete @[id]\n group?.type()?.deleted? group for group in deleted\n\nIf any groups were just introduced to this document by pasting (or by\nprogrammatic insertion), we need to process their connections, because the\ngroups themselves may have had to be given new ids (to preserve uniqueness\nwithin this document) and thus the ids in any of their connections need to\nbe updated to stay internally consistent within the new content.\n\n updateConnections = ( group, inOutBoth = 'both' ) =>\n return unless connections = group.get 'connections'\n id = group.id()\n for connection in connections\n if inOutBoth is 'both' or \\\n ( connection[0] is id and inOutBoth is 'out' )\n if @idConversionMap.hasOwnProperty connection[1]\n connection[1] = @idConversionMap[connection[1]]\n if inOutBoth is 'both' or \\\n ( connection[1] is id and inOutBoth is 'in' )\n if @idConversionMap.hasOwnProperty connection[0]\n connection[0] = @idConversionMap[connection[0]]\n group.set 'connections', connections\n for own originalId, newId of @idConversionMap\n updateConnections @[newId]\n for connection in newGroup.connectionsOut()\n updateConnections @[connection[1]], 'in'\n for connection in newGroup.connectionsIn()\n updateConnections @[connection[0]], 'out'\n\nInvalidate the `ids()` cache\n([defined below](#querying-the-group-hierarchy)) so that the next time that\nfunction is run, it recomputes its results from the newly-generated\nhierarchy in `topLevel`.\n\n delete @idsCache\n\nIf the Overlay plugin is in use, it should now redraw, since the list of\ngroups may have changed. We put it on a slight delay, because there may\nstill be some pending cursor movements that we want to ensure have finished\nbefore this drawing routine is called. At the same time, we also update\nthe enabled/disabled state of group-insertion buttons and menu items.\n\n setTimeout =>\n @editor.Overlay?.redrawContents()\n @updateButtonsAndMenuItems()\n , 0\n isScanning = no\n\nThe above function needs to create instances of the `Group` class, and\nassociate them with their IDs. The following function does so, re-using\ncopies from the cache when possible. When it encounters a duplicate id, it\nrenames it to the first unused number in the document. Note that we cannot\nuse `@freeIds` here, because it is being updated by `@scanDocument()`, so we\nmust use the more expensive version of actually querying the elements that\nexist in the document itself via `getElementById()`.\n\n registerGroup: ( open, close ) =>\n cached = @[id = grouperInfo( open ).id]\n if cached?.open isnt open or cached?.close isnt close\n if @[id]? and not @[id].old\n newId = 0\n doc = @editor.getDoc()\n while doc.getElementById \"open#{newId}\" or \\\n doc.getElementById \"close#{newId}\" then newId++\n open.setAttribute 'id', \"open#{newId}\"\n close.setAttribute 'id', \"close#{newId}\"\n @idConversionMap[id] = newId\n id = newId\n @[id] = new Group open, close, this\n else\n delete @[id].old\n\nAlso, for each group, we inspect whether its groupers have correctly loaded\ntheir images (by checking their `naturalWidth`), because in several cases\n(e.g., content pasted from a different browser tab, or pasted from this same\npage before a page reload, or re-inserted by an undo operation) the object\nURLs for the images can become invalid. Thus to avoid broken images for our\ngroupers, we must recompute their `src` attributes.\n\n if open.naturalWidth is undefined or open.naturalWidth is 0\n @[id].updateGrouper 'open'\n if close.naturalWidth is undefined or close.naturalWidth is 0\n @[id].updateGrouper 'close'\n\nReturn the (old and kept, or newly updated) ID.\n\n id\n\n## Querying the group hierarchy\n\nThe results of the scanning process in [the previous section](#scanning) are\nreadable through the following functions.\n\nThe following method returns a list of all ids that appear in the Groups\nhierarchy, in tree order.\n\n ids: =>\n if not @idsCache?\n @idsCache = [ ]\n recur = ( g ) =>\n @idsCache.push g.id()\n recur child for child in g.children\n recur group for group in @topLevel\n @idsCache\n\nThe following method finds the group for a given open/close grouper element\nfrom the DOM. It returns null if the given object is not an open/close\ngrouper, or does not appear in the group hierarchy.\n\n grouperToGroup: ( grouper ) =>\n if ( id = grouperInfo( grouper )?.id )? then @[id] else null\n\nThe following method finds the deepest group containing a given DOM Node.\nIt does so by a binary search through the groupers array for the closest\ngrouper before the node. If it is an open grouper, the node is in that\ngroup. If it is a close grouper, the node is in its parent group.\n\n groupAboveNode: ( node ) =>\n if ( all = @allGroupers() ).length is 0 then return null\n left = index : 0, grouper : all[0], leftOfNode : yes\n return @grouperToGroup left.grouper if left.grouper is node\n return null if not strictNodeOrder left.grouper, node\n right = index : all.length - 1, grouper : all[all.length - 1]\n return @grouperToGroup right.grouper if right.grouper is node\n return null if strictNodeOrder right.grouper, node\n loop\n if left.grouper is node\n return @grouperToGroup left.grouper\n if right.grouper is node\n return @grouperToGroup right.grouper\n if left.index + 1 is right.index\n return null unless group = @grouperToGroup left.grouper\n return if left.grouper is group.open then group \\\n else group.parent\n middle = Math.floor ( left.index + right.index ) / 2\n if strictNodeOrder all[middle], node\n left =\n index : middle\n grouper : all[middle]\n leftOfNode : yes\n else\n right =\n index : middle\n grouper : all[middle]\n leftOfNode : no\n\nThe following method is like the previous, but instead of computing the\ndeepest group above a given node, it computes the deepest group above a\ngiven cursor position. This must be presented to the method in the form of\nan HTML Range object that has the same start and end nodes and offsets, such\nas one that has been collapsed.\n\n groupAboveCursor: ( cursor ) =>\n if cursor.startContainer?.nodeType is 3 # HTML text node\n return @groupAboveNode cursor.startContainer\n if cursor.startContainer.childNodes.length > cursor.startOffset\n elementAfter =\n cursor.startContainer.childNodes[cursor.startOffset]\n itsGroup = @groupAboveNode elementAfter\n return if itsGroup?.open is elementAfter \\\n then itsGroup.parent else itsGroup\n if cursor.startContainer.childNodes.length > 0\n elementBefore =\n cursor.startContainer.childNodes[cursor.startOffset - 1]\n itsGroup = @groupAboveNode elementBefore\n return if itsGroup?.close is elementBefore \\\n then itsGroup.parent else itsGroup\n @groupAboveNode cursor.startContainer\n\nThe following method generalizes the previous to HTML Range objects that do\nnot have the same starting and ending points. The group returned will be\nthe deepest group containing both ends of the cursor.\n\n groupAboveSelection: ( range ) =>\n\nCompute the complete ancestor chain of the left end of the range.\n\n left = range.cloneRange()\n left.collapse yes\n left = @groupAboveCursor left\n leftChain = [ ]\n while left?\n leftChain.unshift left\n left = left.parent\n\nCompute the complete ancestor chain of the right end of the range.\n\n right = range.cloneRange()\n right.collapse no\n right = @groupAboveCursor right\n rightChain = [ ]\n while right?\n rightChain.unshift right\n right = right.parent\n\nFind the deepest group in both ancestor chains.\n\n result = null\n while leftChain.length > 0 and rightChain.length > 0 and \\\n leftChain[0] is rightChain[0]\n result = leftChain.shift()\n rightChain.shift()\n result\n\n## Change Events\n\nThe following function can be called whenever a certain range in the\ndocument has changed, and groups touching that range need to be updated. It\nassumes that `scanDocument()` was recently called, so that the group\nhierarchy is up-to-date. The parameter must be a DOM Range object.\n\n rangeChanged: ( range ) =>\n group.contentsChanged no for group in @groupsTouchingRange range\n\nThat method uses `@groupsTouchingRange()`, which is implemented below. It\nuses the previous to get a list of all groups that intersect the given DOM\nRange object, in the order in which their close groupers appear (which means\nthat child groups are guaranteed to appear earlier in the list than their\nparent groups).\n\nThe return value will include all groups whose interior or groupers\nintersect the interior of the range. This includes groups that intersect\nthe range only indirectly, by being parents whose children intersect the\nrange, and so on for grandparent groups, etc. When the selection is\ncollapsed, the only \"leaf\" group intersecting it is the one containing it.\n\nThis routine requires that `scanDocument` has recently been called, so that\ngroupers appear in perfectly matched pairs, correctly nested.\n\n groupsTouchingRange: ( range ) =>\n if ( all = @allGroupers() ).length is 0 then return [ ]\n firstInRange = 1 + @grouperIndexOfRangeEndpoint range, yes, all\n lastInRange = @grouperIndexOfRangeEndpoint range, no, all\n\nIf there are no groupers in the selected range at all, then just create the\nparent chain of groups above the closest node to the selection.\n\n if firstInRange > lastInRange\n node = range.startContainer\n if node.nodeType is 1 and \\ # Element, not Text, etc.\n range.startOffset < node.childNodes.length\n node = node.childNodes[range.startOffset]\n group = @groupAboveNode node\n result = if group\n if group.open is node\n if group.parent then [ group.parent ] else [ ]\n else\n [ group ]\n else\n [ ]\n while maybeOneMore = result[result.length-1]?.parent\n result.push maybeOneMore\n return result\n\nOtherwise walk through all the groupers in the selection and push their\ngroups onto a stack in the order that the close groupers are encountered.\n\n stack = [ ]\n result = [ ]\n for index in [firstInRange..lastInRange]\n group = @grouperToGroup all[index]\n if all[index] is group.open\n stack.push group\n else\n result.push group\n stack.pop()\n\nThen push onto the stack any open groupers that aren't yet closed, and any\nancestor groups of the last big group encountered, the only one whose parent\ngroups may not have been seen as open groupers.\n\n while stack.length > 0 then result.push stack.pop()\n while maybeOneMore = result[result.length-1].parent\n result.push maybeOneMore\n result\n\nThe above method uses `@grouperIndexOfRangeEndpoint`, which is defined here.\nIt locates the endpoint of a DOM Range object in the list of groupers in the\neditor. It performs a binary search through the ordered list of groupers.\n\nThe `range` parameter must be a DOM Range object. The `left` paramter\nshould be true if you're asking about the left end of the range, false if\nyou're asking about the right end.\n\nThe return value will be the index into `@allGroupers()` of the last grouper\nbefore the range endpoint. Clearly, then, the grouper on the other side of\nthe range endpoint is the return value plus 1. If no groupers are before\nthe range endpoint, this return value will be -1; a special case of this is\nwhen there are no groupers at all.\n\nThe final parameter is optional; it prevents having to compute\n`@allGroupers()`, in case you already have that data available.\n\n grouperIndexOfRangeEndpoint: ( range, left, all ) =>\n if ( all ?= @allGroupers() ).length is 0 then return -1\n endpoint = if left then Range.END_TO_START else Range.END_TO_END\n isLeftOfEndpoint = ( grouper ) =>\n grouperRange = @editor.getDoc().createRange()\n grouperRange.selectNode grouper\n range.compareBoundaryPoints( endpoint, grouperRange ) > -1\n left = 0\n return -1 if not isLeftOfEndpoint all[left]\n right = all.length - 1\n return right if isLeftOfEndpoint all[right]\n loop\n return left if left + 1 is right\n middle = Math.floor ( left + right ) / 2\n if isLeftOfEndpoint all[middle]\n left = middle\n else\n right = middle\n\n## Drawing Groups\n\nThe following function draws groups around the user's cursor, if any. It is\ninstalled in [the constructor](#groups-constructor) and called by [the\nOverlay plugin](overlayplugin.litcoffee).\n\n drawGroups: ( canvas, context ) =>\n @bubbleTags = [ ]\n\nWe do not draw the groups if document scanning is disabled, because it means\nthat we are in the middle of a change to the group hierarchy, which means\nthat calls to the functions we'll need to figure out what to draw will give\nunstable/incorrect results.\n\n if @scanLocks > 0 then return\n group = @groupAboveSelection @editor.selection.getRng()\n bodyStyle = getComputedStyle @editor.getBody()\n leftMar = parseInt bodyStyle['margin-left']\n rightMar = parseInt bodyStyle['margin-right']\n pad = 3\n padStep = 2\n radius = 4\n tags = [ ]\n\nWe define a group-drawing function that we will call on all groups from\n`group` on up the group hierarchy.\n\n drawGroup = ( group, drawOutline, drawInterior, withTag ) =>\n type = group.type()\n color = type?.color ? '#444444'\n\nCompute the group's boundaries, and if that's not possible, quit this whole\nroutine right now.\n\n if not boundaries = group.getScreenBoundaries()\n setTimeout ( => @editor.Overlay?.redrawContents() ), 100\n return null\n { open, close } = boundaries\n\nPad by `pad/3` in the x direction, `pad` in the y direction, and with corner\nradius `radius`.\n\n x1 = open.left - pad/3\n y1 = open.top - pad\n x2 = close.right + pad/3\n y2 = close.bottom + pad\n\nCompute the group's tag contents, if any, and store where and how to draw\nthem.\n\n if withTag and tagString = type?.tagContents? group\n tags.push\n content : tagString\n corner : { x : x1, y : y1 }\n color : color\n style : createFontStyleString group.open\n group : group\n\nDraw this group, either a rounded rectangle or a \"zone,\" which is a\nrounded rectangle that experienced something like word wrapping.\n\n context.fillStyle = context.strokeStyle = color\n if open.top is close.top and open.bottom is close.bottom\n context.roundedRect x1, y1, x2, y2, radius\n else\n context.roundedZone x1, y1, x2, y2, open.bottom,\n close.top, leftMar, rightMar, radius\n if drawOutline\n context.save()\n context.globalAlpha = 1.0\n context.lineWidth = 1.5\n type?.setOutlineStyle? group, context\n context.stroke()\n context.restore()\n if drawInterior\n context.save()\n context.globalAlpha = 0.3\n type?.setFillStyle? group, context\n context.fill()\n context.restore()\n yes # success\n\nThat concludes the group-drawing function. Let's now call it on all the\ngroups in the hierarchy, from `group` on upwards.\n\n innermost = yes\n walk = group\n while walk\n if not drawGroup walk, yes, innermost, yes then return\n walk = walk.parent\n pad += padStep\n innermost = no\n\nIf the plugin has been extended with a handler that supplies extra visible\ngroups beyond those surrounding the cursor, find those groups and draw them\nnow.\n\n for extra in @visibleGroups?() ? []\n drawGroup extra, yes, no, yes\n\nNow draw the tags on all the bubbles just drawn. We proceed in reverse\norder, so that outer tags are drawn behind inner ones. We also track the\nrectangles we've covered, and move any later ones upward so as not to\ncollide with ones drawn earlier.\n\nWe begin by measuring the sizes of the rectangles, and checking for\ncollisions. Those that collide with previously-scanned rectangles are slid\nupwards so that they don't collide anymore. After all collisions have been\nresolved, the rectangle's bottom boundary is reset to what it originally\nwas, so that the rectangle actually just got taller.\n\n tagsToDraw = [ ]\n while tags.length > 0\n tag = tags.shift()\n context.font = tag.font\n if not size = context.measureHTML tag.content, tag.style\n setTimeout ( => @editor.Overlay?.redrawContents() ), 10\n return\n x1 = tag.corner.x - padStep\n y1 = tag.corner.y - size.height - 2*padStep\n x2 = x1 + 2*padStep + size.width\n y2 = tag.corner.y\n for old in tagsToDraw\n if rectanglesCollide x1, y1, x2, y2, old.x1, old.y1, \\\n old.x2, old.y2\n moveBy = old.y1 - y2\n y1 += moveBy\n y2 += moveBy\n y2 = tag.corner.y\n [ tag.x1, tag.y1, tag.x2, tag.y2 ] = [ x1, y1, x2, y2 ]\n tagsToDraw.unshift tag\n\nNow we draw the tags that have already been sized for us by the previous\nloop.\n\n for tag in tagsToDraw\n context.roundedRect tag.x1, tag.y1, tag.x2, tag.y2, radius\n context.globalAlpha = 1.0\n context.save()\n context.fillStyle = '#ffffff'\n tag.group?.type?().setFillStyle? tag.group, context\n context.fill()\n context.restore()\n context.save()\n context.lineWidth = 1.5\n context.strokeStyle = tag.color\n tag.group?.type?().setOutlineStyle? tag.group, context\n context.stroke()\n context.restore()\n context.save()\n context.globalAlpha = 0.7\n context.fillStyle = tag.color\n tag.group?.type?().setFillStyle? tag.group, context\n context.fill()\n context.restore()\n context.fillStyle = '#000000'\n context.globalAlpha = 1.0\n if not context.drawHTML tag.content, tag.x1 + padStep, \\\n tag.y1, tag.style\n setTimeout ( => @editor.Overlay?.redrawContents() ), 10\n return\n @bubbleTags.unshift tag\n\nIf there is a group the mouse is hovering over, also draw its interior only,\nto show where the mouse is aiming.\n\n pad = 3\n if @groupUnderMouse\n if not drawGroup @groupUnderMouse, no, yes, no then return\n\nIf this group has connections to any other groups, draw them now.\n\nFirst, define a few functions that draw an arrow from one group to another.\nThe label is the optional string tag on the connection, and the index is an\nindex into the list of connections that are to be drawn.\n\n topEdge = ( open, close ) =>\n left :\n x : open.left\n y : open.top\n right :\n x : if open.top is close.top and \\\n open.bottom is close.bottom\n close.right\n else\n canvas.width - rightMar\n y : open.top\n bottomEdge = ( open, close ) =>\n left :\n x : if open.top is close.top and \\\n open.bottom is close.bottom\n open.left\n else\n leftMar\n y : close.bottom\n right :\n x : close.right\n y : close.bottom\n gap = 20\n groupEdgesToConnect = ( fromBds, toBds ) =>\n if fromBds.close.bottom + gap < toBds.open.top\n from : bottomEdge fromBds.open, fromBds.close\n to : topEdge toBds.open, toBds.close\n startDir : 1\n endDir : 1\n else if toBds.close.bottom + gap < fromBds.open.top\n from : topEdge fromBds.open, fromBds.close\n to : bottomEdge toBds.open, toBds.close\n startDir : -1\n endDir : -1\n else\n from : topEdge fromBds.open, fromBds.close\n to : topEdge toBds.open, toBds.close\n startDir : -1\n endDir : 1\n interp = ( left, right, index, length ) =>\n pct = ( index + 1 ) / ( length + 1 )\n right = Math.min right, left + 40 * length\n ( 1 - pct ) * left + pct * right\n drawArrow = ( index, outOf, from, to, label, setStyle ) =>\n context.save()\n context.strokeStyle = from.type()?.color or '#444444'\n context.globalAlpha = 1.0\n context.lineWidth = 2\n setStyle? context\n fromBox = from.getScreenBoundaries()\n toBox = to.getScreenBoundaries()\n if not fromBox or not toBox then return\n fromBox.open.top -= pad\n fromBox.close.top -= pad\n fromBox.open.bottom += pad\n fromBox.close.bottom += pad\n toBox.open.top -= pad\n toBox.close.top -= pad\n toBox.open.bottom += pad\n toBox.close.bottom += pad\n how = groupEdgesToConnect fromBox, toBox\n startX = interp how.from.left.x, how.from.right.x, index,\n outOf\n startY = how.from.left.y\n endX = interp how.to.left.x, how.to.right.x, index, outOf\n endY = how.to.left.y\n context.bezierArrow startX, startY,\n startX, startY + how.startDir * gap,\n endX, endY - how.endDir * gap, endX, endY\n context.stroke()\n if label isnt ''\n centerX = context.applyBezier startX, startX, endX,\n endX, 0.5\n centerY = context.applyBezier startY,\n startY + how.startDir * gap,\n endY - how.endDir * gap, endY, 0.5\n style = createFontStyleString from.open\n if not size = context.measureHTML label, style\n setTimeout ( => @editor.Overlay?.redrawContents() ),\n 10\n return\n context.roundedRect \\\n centerX - size.width / 2 - padStep,\n centerY - size.height / 2 - padStep,\n centerX + size.width / 2 + padStep,\n centerY + size.width / 2, radius\n context.globalAlpha = 1.0\n context.fillStyle = '#ffffff'\n context.fill()\n context.lineWidth = 1.5\n context.strokeStyle = from.type()?.color ? '#444444'\n context.stroke()\n context.fillStyle = '#000000'\n context.globalAlpha = 1.0\n context.drawHTML label,\n centerX - size.width / 2 + padStep,\n centerY - size.height / 2, style\n context.restore()\n\nSecond, draw all connections from the innermost group containing the cursor,\nif there are any, plus connections from any groups registered as visible\nthrough the `visibleGroups` handler. The connections arrays are permitted\nto contain group indices or actual groups; the former will be converted to\nthe latter if needed.\n\n for g in [ group, ( @visibleGroups?() ? [] )... ]\n if g\n connections = g.type().connections? g\n numArrays = ( c for c in connections \\\n when c instanceof Array ).length\n for connection in connections ? [ ]\n if connection not instanceof Array\n if typeof( connection ) is 'number'\n connection = @[connection]\n drawGroup connection, yes, no, no\n for connection, index in connections ? [ ]\n if connection instanceof Array\n from = if typeof( connection[0] ) is 'number' \\\n then @[connection[0]] else connection[0]\n to = if typeof( connection[1] ) is 'number' \\\n then @[connection[1]] else connection[1]\n drawArrow index, numArrays, from, to,\n connection[2..]...\n\n# Installing the plugin\n\nThe plugin, when initialized on an editor, places an instance of the\n`Groups` class inside the editor, and points the class at that editor.\n\n styleSheet = '\n img.grouper {\n margin-bottom : -0.35em;\n cursor : default;\n }\n\n img.grouper.hide:not(.decorate) {\n width : 0px;\n height : 22px;\n }\n '\n styleSheet = 'data:text/css;base64,' + btoa styleSheet\n tinymce.PluginManager.add 'groups', ( editor, url ) ->\n editor.Groups = new Groups editor\n editor.on 'init', ( event ) -> editor.dom.loadCSS styleSheet\n for type in editor.settings.groupTypes\n editor.Groups.addGroupType type.name, type\n editor.addMenuItem 'hideshowgroups',\n text : 'Hide/show groups'\n context : 'View'\n onclick : -> editor.Groups.hideOrShowGroupers()\n\nApplications which want to use arrows among groups often want to give the\nuser a convenient way to connect groups visually. We provide the following\nfunction that installs a handy UI for doing so. This function should be\ncalled before `tinymce.init`, which means at page load time, not thereafter.\n\n if window.useGroupConnectionsUI\n editor.addButton 'connect',\n image : htmlToImage '↗'\n tooltip : 'Connect groups'\n onclick : ->\n @active not @active()\n editor.Groups.updateConnectionsMode()\n onPostRender : ->\n editor.Groups.connectionsButton = this\n editor.Groups.updateButtonsAndMenuItems()\n\nThe document needs to be scanned (to rebuild the groups hierarchy) whenever\nit changes. The editor's change event is not reliable, in that it fires\nonly once at the beginning of any sequence of typing. Thus we watch not\nonly for change events, but also for KeyUp events. We filter the latter so\nthat we do not rescan the document if the key in question was only an arrow\nkey or home/end/pgup/pgdn.\n\nIn addition to rescanning the document, we also call the `rangeChanged`\nevent of the Groups plugin, to update any groups that overlap the range in\nwhich the document was modified.\n\nNote that the `SetContent` event is what fires when the user invokes the\nundo or redo action, but the range is the entire document. Thus this event\nhandler automatically sends change events to *all* groups in the document\nwhenever the user chooses undo or redo.\n\n editor.on 'change SetContent', ( event ) ->\n editor.Groups.scanDocument()\n if event?.level?.bookmark\n orig = editor.selection.getBookmark()\n editor.selection.moveToBookmark event.level.bookmark\n range = editor.selection.getRng()\n editor.selection.moveToBookmark orig\n editor.Groups.rangeChanged range\n editor.on 'KeyUp', ( event ) ->\n movements = [ 33..40 ] # arrows, pgup/pgdn/home/end\n modifiers = [ 16, 17, 18, 91 ] # alt, shift, ctrl, meta\n if event.keyCode in movements or event.keyCode in modifiers\n return\n editor.Groups.scanDocument()\n editor.Groups.rangeChanged editor.selection.getRng()\n\nWhenever the cursor moves, we should update whether the group-insertion\nbuttons and menu items are enabled.\n\n editor.on 'NodeChange', ( event ) ->\n editor.Groups.updateButtonsAndMenuItems()\n\nThe following handler installs a context menu that is exactly like that\ncreated by the TinyMCE context menu plugin, except that it appends to it\nany custom menu items needed by any groups inside which the user clicked.\n\n editor.on 'contextMenu', ( event ) ->\n\nPrevent the browser's context menu.\n\n event.preventDefault()\n\nFigure out where the user clicked, and whether there are any groups there.\n\n x = event.clientX\n y = event.clientY\n if node = editor.getDoc().nodeFromPoint x, y\n group = editor.Groups.groupAboveNode node\n\nCompute the list of normal context menu items.\n\n contextmenu = editor.settings.contextmenu or \\\n 'link image inserttable | cell row column deletetable'\n items = [ ]\n for name in contextmenu.split /[ ,]/\n item = editor.menuItems[name]\n if name is '|' then item = text : name\n if item then item.shortcut = '' ; items.push item\n\nAdd any group-specific context menu items.\n\n if newItems = group?.type()?.contextMenuItems group\n items.push text : '|'\n items = items.concat newItems\n\nConstruct the menu and show it on screen.\n\n menu = new tinymce.ui.Menu(\n items : items\n context : 'contextmenu'\n classes : 'contextmenu'\n ).renderTo()\n editor.on 'remove', -> menu.remove() ; menu = null\n pos = ( $ editor.getContentAreaContainer() ).position()\n menu.moveTo x + pos.left, y + pos.top\n\nThere are two actions the plugin must take on the mouse down event in the\neditor.\n\nIn connection-making mode, if the user clicks inside a bubble, we must\nattempt to form a connection between the group the cursor is currently in\nand the group in which the user clicked.\n\nOtherwise, if the user clicks in a bubble tag, we must discern which bubble\ntag received the click, and trigger the tag menu for that group, if it\ndefines one. We use the mousedown event rather than the click event,\nbecause the mousedown event is the only one for which `preventDefault()` can\nfunction. By the time the click event happens (strictly after mousedown), it\nis too late to prevent the default handling of the event.\n\n editor.on 'mousedown', ( event ) ->\n x = event.clientX\n y = event.clientY\n\nFirst, the case for connection-making mode.\n\n if editor.Groups.connectionsButton?.active()\n if group = editor.groupUnderMouse x, y\n left = editor.selection?.getRng()?.cloneRange()\n if not left then return\n left.collapse yes\n currentGroup = editor.Groups.groupAboveCursor left\n currentGroup.type()?.connectionRequest? currentGroup,\n group\n event.preventDefault()\n editor.Groups.connectionsButton?.active false\n editor.Groups.updateConnectionsMode()\n return no\n return\n\nNext, the case for clicking a grouper.\n\n doc = editor.getDoc()\n el = doc.elementFromPoint x, y\n if el and info = grouperInfo el\n group = editor.Groups.grouperToGroup el\n group.type()?.clicked? group, 'single',\n if el is group.open then 'open' else 'close'\n return no\n\nLast, the case for clicking bubble tags.\n\n for tag in editor.Groups.bubbleTags\n if tag.x1 < x < tag.x2 and tag.y1 < y < tag.y2\n menuItems = tag.group?.type()?.tagMenuItems tag.group\n menuItems ?= [\n text : 'no actions available'\n disabled : true\n ]\n menu = new tinymce.ui.Menu(\n items : menuItems\n context : 'contextmenu'\n classes : 'contextmenu'\n ).renderTo()\n editor.on 'remove', -> menu.remove() ; menu = null\n pos = ( $ editor.getContentAreaContainer() ).position()\n menu.moveTo x + pos.left, y + pos.top\n event.preventDefault()\n return no\n\nNow we install a handler for double-clicking group boundaries (\"groupers\").\n\n editor.on 'dblclick', ( event ) ->\n doc = editor.getDoc()\n el = doc.elementFromPoint event.clientX, event.clientY\n if el and info = grouperInfo el\n group = editor.Groups.grouperToGroup el\n group.type()?.clicked? group, 'double',\n if el is group.open then 'open' else 'close'\n return no\n\nThe following functions install an event handler that highlights the\ninnermost group under the mouse pointer at all times.\n\n editor.on 'mousemove', ( event ) ->\n editor.Groups.groupUnderMouse =\n editor.groupUnderMouse event.clientX, event.clientY\n editor.Overlay?.redrawContents()\n\nThe previous two functions both leverage the following utility.\n\n editor.groupUnderMouse = ( x, y ) ->\n doc = editor.getDoc()\n el = doc.elementFromPoint x, y\n for i in [0...el.childNodes.length]\n node = el.childNodes[i]\n if node.nodeType is 3\n range = doc.createRange()\n range.selectNode node\n rects = range.getClientRects()\n rects = ( rects[i] for i in [0...rects.length] )\n for rect in rects\n if x > rect.left and x < rect.right and \\\n y > rect.top and y < rect.bottom\n return editor.Groups.groupAboveNode node\n null\n\n## LaTeX-like shortcuts for groups\n\nNow we install code that watches for certain text sequences that should be\ninterpreted as the insertion of groups.\n\nThis relies on the KeyUp event, which may only fire once for a few quick\nsuccessive keystrokes. Thus someone typing very quickly may not have these\nshortcuts work correctly for them, but I do not yet have a workaround for\nthis behavior.\n\n editor.on 'KeyUp', ( event ) ->\n movements = [ 33..40 ] # arrows, pgup/pgdn/home/end\n modifiers = [ 16, 17, 18, 91 ] # alt, shift, ctrl, meta\n if event.keyCode in movements or event.keyCode in modifiers\n return\n range = editor.selection.getRng()\n if range.startContainer is range.endContainer and \\\n range.startContainer?.nodeType is 3 # HTML Text node\n allText = range.startContainer.textContent\n lastCharacter = allText[range.startOffset-1]\n if lastCharacter isnt ' ' and lastCharacter isnt '\\\\' and \\\n lastCharacter isnt String.fromCharCode( 160 )\n return\n allBefore = allText.substr 0, range.startOffset - 1\n allAfter = allText.substring range.startOffset - 1\n for typeName, typeData of editor.Groups.groupTypes\n if shortcut = typeData.LaTeXshortcut\n if allBefore[-shortcut.length..] is shortcut\n newCursorPos = range.startOffset -\n shortcut.length - 1\n if lastCharacter isnt '\\\\'\n allAfter = allAfter.substr 1\n allBefore = allBefore[...-shortcut.length]\n range.startContainer.textContent =\n allBefore + allAfter\n range.setStart range.startContainer,\n newCursorPos\n if lastCharacter is '\\\\'\n range.setEnd range.startContainer,\n newCursorPos + 1\n else\n range.setEnd range.startContainer,\n newCursorPos\n editor.selection.setRng range\n editor.Groups.groupCurrentSelection typeName\n break\n\n\n# MediaWiki Integration\n\n[MediaWiki](https://www.mediawiki.org/wiki/MediaWiki) is the software that\npowers [Wikipedia](wikipedia.org). We plan to integrate webLurch with a\nMediaWiki instance by adding features that let the software load pages from\nthe wiki into webLurch for editing, and easily post changes back to the\nwiki as well. This plugin implements that two-way communication.\n\nThis first version is a start, and does not yet implement full\nfunctionality.\n\n## Global variable\n\nWe store the editor into which we're installed in this global variable, so\nthat we can access it easily later. We initialize it to null here.\n\n editor = null\n\n## Setup\n\nBefore you do anything else with this plugin, you must specify the URLs for\nthe wiki's main page (usually index.php) and API page (usually api.php).\nDo so with the following functions.\n\n setIndexPage = ( URL ) -> editor.indexURL = URL\n getIndexPage = -> editor.indexURL\n setAPIPage = ( URL ) -> editor.APIURL = URL\n getAPIPage = -> editor.APIURL\n\n## Extracting wiki pages\n\nThe following (necessarily asynchronous) function accesses the wiki, fetches\nthe content for the page with the given name, and sends it to the given\ncallback. The callback takes two parameters, the content and an error.\nOnly one will be non-null, depending on the success or failure of the\nprocess.\n\nThis internal function therefore does the grunt work. It can fetch any data\nabout a wiki page using the `rvprop` parameter of [the MediaWiki Revisions\nAPI](https://www.mediawiki.org/wiki/API:Revisions). Two convenience\nfunctions for common use cases follow.\n\n getPageData = ( pageName, rvprop, callback ) ->\n xhr = new XMLHttpRequest()\n xhr.addEventListener 'load', ->\n json = @responseText\n try\n object = JSON.parse json\n catch e\n callback null,\n 'Invalid response format.\\nShould be JSON:\\n' + json\n return\n try\n content = object.query.pages[0].revisions[0][rvprop]\n catch e\n callback null, 'No such page on wiki.\\nRaw reply:\\n' + json\n return\n callback content, null\n xhr.open 'GET',\n editor.MediaWiki.getAPIPage() + '?action=query&titles=' + \\\n encodeURIComponent( pageName ) + \\\n '&prop=revisions' + \\\n '&rvprop=' + rvprop + '&rvparse' + \\\n '&format=json&formatversion=2'\n xhr.setRequestHeader 'Api-User-Agent', 'webLurch application'\n xhr.send()\n\nInserting the response data from this function into the editor happens in\nthe function after this one.\n\n getPageContent = ( pageName, callback ) ->\n getPageData pageName, 'content', callback\n\nThis function is very similar to `getPageContent`, but gets the last\nmodified date of the page instead of its content.\n\n getPageTimestamp = ( pageName, callback ) ->\n getPageData pageName, 'timestamp', callback\n\nThe following function wraps `getPageContent` in a simple UI, which either\ninserts the fetched content into the editor on success, or pops up an error\ninformation dialog on failure. An optional callback will be called with\ntrue or false, indicating success or failure.\n\n importPage = ( pageName, callback ) ->\n editor.MediaWiki.getPageContent pageName, ( content, error ) ->\n if error\n editor.Dialogs.alert\n title : 'Wiki Error'\n message : \"

Error loading content from wiki:

\n

#{error.split( '\\n' )[0]}

\"\n console.log error\n callback? false # failure\n { metadata, document } = editor.Storage.extractMetadata content\n if not metadata?\n editor.Dialogs.alert\n title : 'Not a Lurch document'\n message : '

The wiki page that you attempted to\n import is not a Lurch document.

\n

Although it is possible to import any wiki page\n into Lurch, it does not work well to edit and\n re-post such pages to the wiki.

\n

To edit a non-Lurch wiki page, visit the page on\n the wiki and edit it there.

'\n callback? false # failure\n editor.setContent document\n callback? document, metadata # success\n\nA variant of the previous function silently attempts to fetch just the\nmetadata from a document stored in the wiki. It calls the callback with\nnull on any failure, and the metadata as JSON on success.\n\n getPageMetadata = ( pageName, callback ) ->\n editor.MediaWiki.getPageContent pageName, ( content, error ) ->\n callback? if error then null else \\\n editor.Storage.extractMetadata( content ).metadata\n\nThe following function accesses the wiki, logs in using the given username\nand password, and sends the results to the given callback. The \"token\"\nparameter is for recursive calls only, and should not be provided by\nclients. The callback accepts result and error parameters. The result will\neither be true, in which case login succeeded, or null, in which case the\nerror parameter will contain the error message as a string.\n\n login = ( username, password, callback, token ) ->\n xhr = new XMLHttpRequest()\n xhr.addEventListener 'load', ->\n json = @responseText\n try\n object = JSON.parse json\n catch e\n callback null, 'Invalid JSON response: ' + json\n return\n if object?.login?.result is 'Success'\n callback true, null\n else if object?.login?.result is 'NeedToken'\n editor.MediaWiki.login username, password, callback,\n object.login.token\n else\n callback null, 'Login error of type ' + \\\n object?.login?.result\n URL = editor.MediaWiki.getAPIPage() + '?action=login' + \\\n '&lgname=' + encodeURIComponent( username ) + \\\n '&lgpassword=' + encodeURIComponent( password ) + \\\n '&format=json&formatversion=2'\n if token then URL += '&lgtoken=' + token\n xhr.open 'POST', URL\n xhr.setRequestHeader 'Api-User-Agent', 'webLurch application'\n xhr.send()\n\nThe following function accesses the wiki, attempts to overwrite the page\nwith the given name, using the given content (in wikitext form), and then\ncalls the given callback with the results. That callback should take two\nparameters, result and error. If result is `'Success'` then error will be\nnull, and the edit succeeded. If result is null, then the error will be a\nstring explaining the problem.\n\nNote that if the posting you attempt to do with the following function would\nneed a certain user's access rights to complete it, you should call the\n`login()` function, above, first, to establish that access. Call this one\nfrom its callback (or any time thereafter).\n\n exportPage = ( pageName, content, callback ) ->\n xhr = new XMLHttpRequest()\n xhr.addEventListener 'load', ->\n json = @responseText\n try\n object = JSON.parse json\n catch e\n callback null, 'Invalid JSON response: ' + json\n return\n if not object?.query?.tokens?.csrftoken\n callback null, 'No token provided: ' + json\n return\n xhr2 = new XMLHttpRequest()\n xhr2.addEventListener 'load', ->\n json = @responseText\n try\n object = JSON.parse json\n catch e\n callback null, 'Invalid JSON response: ' + json\n return\n # callback JSON.stringify object, null, 4\n if object?.edit?.result isnt 'Success'\n callback null, 'Edit failed: ' + json\n return\n callback 'Success', null\n content = formatContentForWiki content\n xhr2.open 'POST',\n editor.MediaWiki.getAPIPage() + '?action=edit' + \\\n '&title=' + encodeURIComponent( pageName ) + \\\n '&text=' + encodeURIComponent( content ) + \\\n '&summary=' + encodeURIComponent( 'posted from Lurch' ) + \\\n '&contentformat=' + encodeURIComponent( 'text/x-wiki' ) + \\\n '&contentmodel=' + encodeURIComponent( 'wikitext' ) + \\\n '&format=json&formatversion=2', true\n token = 'token=' + \\\n encodeURIComponent object.query.tokens.csrftoken\n xhr2.setRequestHeader 'Content-type',\n 'application/x-www-form-urlencoded'\n xhr2.setRequestHeader 'Api-User-Agent', 'webLurch application'\n xhr2.send token\n xhr.open 'GET',\n editor.MediaWiki.getAPIPage() + '?action=query&meta=tokens' + \\\n '&format=json&formatversion=2'\n xhr.setRequestHeader 'Api-User-Agent', 'webLurch application'\n xhr.send()\n\nThe previous function makes use of the following one. This depends upon the\n[HTMLTags](https://www.mediawiki.org/wiki/Extension:HTML_Tags) extension to\nMediaWiki, which permits arbitrary HTML, as long as it is encoded using tags\nof a certain form, and the MediaWiki configuration permits the tags. See\nthe documentation for the extension for details.\n\n formatContentForWiki = ( editorHTML ) ->\n result = ''\n depth = 0\n openRE = /^<([^ >]+)\\s*([^>]+)?>/i\n closeRE = /^<\\/([^ >]+)\\s*>/i\n charRE = /^&([a-z0-9]+|#[0-9]+);/i\n toReplace = [ 'img', 'span', 'var', 'sup' ]\n decoder = document.createElement 'div'\n while editorHTML.length > 0\n if match = closeRE.exec editorHTML\n tagName = match[1].toLowerCase()\n if tagName in toReplace\n depth--\n result += \"\"\n else\n result += match[0]\n editorHTML = editorHTML[match[0].length..]\n else if match = openRE.exec editorHTML\n tagName = match[1].toLowerCase()\n if tagName in toReplace\n result += \"\"\n if not /\\/\\s*$/.test match[2] then depth++\n else\n result += match[0]\n editorHTML = editorHTML[match[0].length..]\n else if match = charRE.exec editorHTML\n decoder.innerHTML = match[0]\n result += decoder.textContent\n editorHTML = editorHTML[match[0].length..]\n else\n result += editorHTML[0]\n editorHTML = editorHTML[1..]\n result\n\n# Installing the plugin\n\nThe plugin, when initialized on an editor, installs all the functions above\ninto the editor, in a namespace called `MediaWiki`.\n\n tinymce.PluginManager.add 'mediawiki', ( ed, url ) ->\n ( editor = ed ).MediaWiki =\n setIndexPage : setIndexPage\n getIndexPage : getIndexPage\n setAPIPage : setAPIPage\n getAPIPage : getAPIPage\n login : login\n getPageContent : getPageContent\n getPageTimestamp : getPageTimestamp\n importPage : importPage\n exportPage : exportPage\n getPageMetadata : getPageMetadata\n\n\n# Overlay Plugin for [TinyMCE](http://www.tinymce.com)\n\nThis plugin creates a canvas element that sits directly on top of the\neditor. It is transparent, and thus invisible, unless items are drawn on\nit; hence it functions as an overlay. It also passes all mouse and keyboard\nevents through to the elements beneath it, so it does not interefere with\nthe functionality of the rest of the page in that respect.\n\n# `Overlay` class\n\nWe begin by defining a class that will contain all the information needed\nabout the overlay element and how to use it. An instance of this class will\nbe stored as a member in the TinyMCE editor object.\n\nThis convention is adopted for all TinyMCE plugins in the Lurch project;\neach will come with a class, and an instance of that class will be stored as\na member of the editor object when the plugin is installed in that editor.\nThe presence of that member indicates that the plugin has been installed,\nand provides access to the full range of functionality that the plugin\ngrants to that editor.\n\n class Overlay\n\nWe construct new instances of the Overlay class as follows, and these are\ninserted as members of the corresponding editor by means of the code [below,\nunder \"Installing the Plugin.\"](#installing-the-plugin)\n\n constructor: ( @editor ) ->\n\nThe first task of the constructor is to create and style the canvas element,\ninserting it at the appropriate place in the DOM. The following code does\nso. Note the use of `rgba(0,0,0,0)` for transparency, the `pointer-events`\nattribute for ignoring mouse clicks, and the fact that the canvas is a child\nof the same container as the editor itself.\n\n @editor.on 'init', =>\n @container = @editor.getContentAreaContainer()\n @canvas = document.createElement 'canvas'\n ( $ @container ).after @canvas\n @canvas.style.position = 'absolute'\n @canvas.style['background-color'] = 'rgba(0,0,0,0)'\n @canvas.style['pointer-events'] = 'none'\n @canvas.style['z-index'] = '10'\n\nWe then allow any client to register drawing routines with this plugin, and\nall registered routines will be called (in the order in which they were\nregistered) every time the canvas needs to be redrawn. The following line\ninitializes the list of drawing handlers to empty.\n\n @drawHandlers = []\n @editor.on 'NodeChange', @redrawContents\n ( $ @editor.getContentAreaContainer() ).resize @redrawContent\n\nThis function installs an event handler that, each time something in the\ndocument changes, repositions the canvas, clears it, and runs all drawing\nhandlers.\n\n redrawContents: ( event ) =>\n @positionCanvas()\n if not context = @canvas?.getContext '2d' then return\n @clearCanvas context\n context.translate 0, ( $ @container ).position().top\n for doDrawing in @drawHandlers\n try\n doDrawing @canvas, context\n catch e\n console.log \"Error in overlay draw function: #{e.stack}\"\n\nThe following function permits the installation of new drawing handlers.\nEach will receive two parameters (as shown in the code immediately above),\nthe first being the canvas on which to draw, and the second being the\ndrawing context.\n\n addDrawHandler: ( drawFunction ) -> @drawHandlers.push drawFunction\n\nThis function is part of the private API, and is used only by\n`positionCanvas`, below. It fetches the `