├── .gitattributes ├── .gitignore ├── README.md ├── css ├── jquery.contextMenu.css └── styles.css ├── img ├── example.PNG ├── example2.png ├── graphpaper-background.png ├── pointer.png └── textEdit.png ├── index.html ├── js ├── demo.data.js ├── demo.js ├── ko.extensions.js ├── lib │ ├── _notes │ │ └── dwsync.xml │ ├── jquery.contextMenu.js │ ├── jquery.jsPlumb-1.5.5-min.js │ └── jquery.jsPlumb-1.5.5.js ├── listViewModel.js ├── plumbing.js ├── resultViewModel.js ├── stepViewModel.js ├── templateViewModel.js ├── view.js └── workflowViewModel.js └── templates ├── 1.png ├── 10.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── 6.png ├── 7.png ├── 8.png └── 9.png /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # ========================= 18 | # Operating System Files 19 | # ========================= 20 | 21 | # OSX 22 | # ========================= 23 | 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | # Icon must ends with two \r. 29 | Icon 30 | 31 | # Thumbnails 32 | ._* 33 | 34 | # Files that might appear on external disk 35 | .Spotlight-V100 36 | .Trashes 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Workflow 2 | ========= 3 | 4 | Workflow provides a way to create visual flowcharts of workflow data. It is a single page application built with HTML, CSS, and JavaScript. 5 | 6 | > [View Demo](http://workflow.webdemo.imprezzio.com) 7 | 8 | Dependencies 9 | ----------- 10 | 11 | Workflow uses a number of open source projects to work properly: 12 | 13 | * [jQuery] - as usual. 14 | * [jQueryUI] - for drag and drop and other such ui events. 15 | * [jsPlumb] - for making visual connections (results). 16 | * [knockoutjs] - for updating viewmodels with user input. 17 | * [knockout mapping plugin] - for binding hardcoded data in "demo.data.js" to viewmodels. 18 | * [jquery.cookie] - for remembering which workflow user was last working on to load on next visit. 19 | * [jQuery contextMenu] - neat plugin used for adding steps via a context menu (right click). 20 | * [Twitter Bootstrap] - for some of the ui components and styling. 21 | * [Bootbox.js] - for nice looking alert, confirm, and prompt dialogs. 22 | * [Modernizr] - detects whether or not user's browser has local storage enabled for saving workflows. 23 | 24 | --- 25 | 26 | The Scripts 27 | -------------- 28 | 29 | * **ko.extensions.js** 30 | Contains definitions for custom event bindings (saving on ENTER press, etc.). 31 | 32 | * **demo.data.js** 33 | Since it's just a demo, here are some hardcoded array objects used for binding data to dropdowns, template list, etc. Contains definitions for ContactTypes, UserTypes, and Templates - used for emails and mailings. 34 | 35 | * **workflowViewModel.js** 36 | Is the parent ViewModel for all other ViewModels. It keeps track of the workflow currently being worked on as well as a list of all saved workflows. It will get/save/delete workflow data from local storage. When the page loads, it'll check for a 'selected_workflow' cookie and load that workflow if it exists. It also contains the jsPlumb event listeners for adding/removing connections or 'results'. 37 | 38 | * **listViewModel.js** 39 | Defines saved workflows. Contains the local storage key and user given name. 40 | 41 | * **stepViewModel.js** 42 | Defines steps (draggable boxes). Contains user editable data such as name, contact type, description, etc. Also contains it's x and y position on the screen so it'll be put in the right place when loaded. 43 | 44 | * **resultViewModel.js** 45 | Defines results (connection between 2 boxes). Contains the ids of the result's source and target steps, as well as the source and target endpoint locations, such as "Bottom" and "Left". Also contains user editable info displayed on the connection label. Finally, contains the connection id 'conn_id' given to it when a jsPlumb connection is created. 46 | 47 | * **templateViewModel.js** 48 | Defines templates (used in steps with a contact type of email or mailing). Contains the id and name of the template. 49 | 50 | * **demo.js** 51 | Creates templateViewModels (from demo.data.js) and listViewModels (from local storage) pushes them into the workflowViewModel. 52 | 53 | * **plumbing.js** 54 | Contains settings for the jsPlumb connections and endpoints. Contains functions for turning 'step' divs into jsPlumb objects, adding/removing endpoints from them, and connecting them when a workflow is loaded. 55 | 56 | * **view.js** 57 | Contains a few functions related to ui interactions, like showing the saved workflow list, hiding the help box, etc. 58 | 59 | --- 60 | 61 | Workflow Anatomy 62 | -------------- 63 | 64 | Here's a simple workflow example: 65 | 66 | ![Workflow Example](http://workflow.webdemo.imprezzio.com/img/example.png) 67 | 68 | This would get saved as: 69 | ```json 70 | { 71 | "name": "Workflow Example", 72 | "steps": [{ 73 | "isStart": true, 74 | "Id": 1, 75 | "name": "Step 1", 76 | "contactType": 1, 77 | "userType": 1, 78 | "description": "I'm the first step", 79 | "template": null, 80 | "top": "80px", 81 | "left": "20px" 82 | }, 83 | { 84 | "isStart": false, 85 | "Id": 2, 86 | "name": "Step 2", 87 | "contactType": 3, 88 | "userType": 1, 89 | "description": "I'm the second step", 90 | "template": null, 91 | "top": "80px", 92 | "left": "320px" 93 | }], 94 | "results": [{ 95 | "Id": 1, 96 | "sourceId": "1", 97 | "targetId": "2", 98 | "sourceEndpoint": "Right", 99 | "targetEndpoint": "Left", 100 | "label": "I'm a result", 101 | "daysLater": 1 102 | }] 103 | } 104 | ``` 105 | You can see, here we have 2 steps and 1 result. The "result" represents a jsPlumb [connector]. By saving the result's source and target ids and [endpoint] locations, we can tell jsPlumb to draw this [connection] when this workflow gets loaded in the future. By saving the top and left positions of step divs, we can also put them back in the right place when the workflow gets loaded. 106 | 107 | **Some terms to note:** 108 | 109 | ![Workflow Example](http://workflow.webdemo.imprezzio.com/img/example2.png) 110 | 111 | --- 112 | 113 | The ViewModels 114 | -------------- 115 | 116 | 117 | ###WorkflowViewModel 118 | 119 | ```js 120 | - contactTypes: mapping.fromJS //call, mailing, appointment, etc. 121 | - userTypes: mapping.fromJS //all agency users, full time, etc. 122 | - templateTypes: mapping.fromJS //some email template imgs 123 | - allWorkflows: observableArray //gets populated with saved workflows when page loads 124 | - workflows: observableArray //keeps track of saved workflows during user session 125 | - steps: observableArray //draggable boxes in selected workflow 126 | - results: observableArray //connections between boxes in selected workflow 127 | - templates: observableArray //for rendering template list in the html 128 | - selectedTemplate: observable //contains the value of selected template id for a given step 129 | - tempId: observable //stores the stepId to know which step to update when a template is selected 130 | - counter: 0 //for updating result conn_id's when loading a saved workflow 131 | - isDirty: observable //bool to check if there are unsaved changes to the workflow 132 | --------------------------------------------------------------------------------------------------- 133 | - addNewStep(contactType, top, left) 134 | - addNewResult(info) 135 | - updateResult(info) 136 | - removeResult(info) 137 | - showResult(connection) //shows form for updating result label data -> label, daysLater 138 | - showTemplates(data, event) //shows the template select dialog 139 | - setTemplate //updates appropriate step with templateId selected in template dialog 140 | - getNewId(isResult) //assigns a new id to steps and results when created 141 | - saveOrCancelAllEdits 142 | - saveData //saves workflow to local storage as a json string 143 | - selectWorkflow //gets saved workflow from local storage 144 | - newWorkflow 145 | - deleteAllWorkflows 146 | - clearData //clears workFlowViewModel of steps, results. used when starting a new workflow, etc. 147 | ``` 148 | 149 | **jsPlumb event bindings** 150 | 151 | * [connection] -> addNewResult(info) 152 | * [connectionDetached](http://jsplumbtoolkit.com/doc/events.html#evt-connection-detached) -> removeResult(info) 153 | * [connectionMoved](http://jsplumbtoolkit.com/doc/events.html#evt-connection-moved) -> updateResult(info) 154 | * [click](http://jsplumbtoolkit.com/doc/events.html#evt-click) -> showResult(connection) 155 | 156 | 157 | 158 | ###ListViewModel 159 | 160 | ```js 161 | - key: observable //local storage key 162 | - name: observable //user-given name 163 | - isSelected: observable //currently being worked on 164 | - isActive: observable //name is being edited 165 | - oldName: //for recovering old name value when name editing is aborted (esc key) or new value is invalid (null) 166 | - -------------------------------------------------------------------------------------------------------------- 167 | - editWorkflowName(data) 168 | - saveWorkflowName 169 | - cancelEditWorkflowName 170 | - saveOrCancelEdit 171 | - deleteWorkflow 172 | ``` 173 | 174 | ###StepViewModel 175 | 176 | ```js 177 | - isStart: observable //bool starting step, only and exactly 1 per workflow 178 | - id: observable 179 | - name: observable 180 | - contactType: observable 181 | - userType: observable 182 | - description: observable 183 | - template: observable //id of selected template 184 | - top: observable //pixels from top 185 | - left: observable //pixels from left 186 | - isActive: observable //some data is being edited 187 | - activeField: observable //which field is being edited, passed by A tag in data-update attribute 188 | - oldName: null //for recovering old value 189 | - oldDesc: null //for recovering old value 190 | - ------------------------------------------------------------------------------------------------------------ 191 | - saveStep 192 | - deleteStep 193 | - editStep(data) 194 | - cancelEditStep 195 | - saveOrCancelEdit 196 | - setIsStart(data) //sets step isStart bool to true, sets all other steps isStart to false 197 | ``` 198 | 199 | ###ResultViewModel 200 | 201 | ```js 202 | - id: observable 203 | - sourceId: observable //id of step that's the source of the connection 204 | - targetId: observable //id of step that's the target of the connection 205 | - sourceEndpoint: observable //name of the source's endpoint location - 'Bottom' 206 | - targetEndpoint: observable //name of the target's endpoint location - 'Left' 207 | - label: observable //string 208 | - daysLater: observable //int 209 | - conId: observable //stores id given by jsPlumb when connection is made 210 | - top: observable //helper for knowing where to display the label edit form, based on label position 211 | - left: observable //same as above 212 | - width: observable //same as above 213 | - isActive: observable //currently being edited 214 | - oldLabel: null //for recovering old value 215 | - oldDaysLater: null //for recovering old value 216 | -------------------------------------------------------------------------------------------------------------- 217 | - saveResult 218 | - deleteResult 219 | - cancelEditResult 220 | - saveOrCancelEdit 221 | ``` 222 | 223 | ###TemplateViewModel 224 | 225 | ```js 226 | - id: observable //from demo.data.js 227 | - name: observable //from demo.data.js 228 | - isSelected: observable //set to true if the id matches template id of the selected step passed down from the parent workflowViewModel. 229 | -------------------------------------------------------------------------------------------------------------- 230 | - showImg //shows img associated with template on mouseover 231 | - hideImg //hides img on mouseout 232 | - setSelected(data) //passes user selected template id back to parent workflowViewModel so it can update the step's template observable with the right value 233 | ``` 234 | 235 | --- 236 | 237 | jsPlumb Elements & Connections 238 | -------------- 239 | **plumbing.js** defines the **plumbing** object, which contains setting info for [connector]s, [endpoint]s, and [label]s. It also contains functions for adding endpoints to 'step' DIVs, making 'step' DIVs jsPlumb [draggable] elements, and programtically creating and removing jsPlumb [connection]s. 240 | 241 | * **styles** 242 | connection styles & endpoint hover styles 243 | 244 | * **aSourceEndpoint** 245 | source endpoint settings 246 | 247 | * **aTargetEndpoint** 248 | target endpoint settings 249 | 250 | * **addEndpoints(id, sourceAnchors, targetAnchors)** 251 | Adds endpoints to an element based on id. Also takes sourceAnchors and targetAnchors arrays that define positions for where to place the end points - ["Top", "Left"], ["Bottom", "Right"]. 252 | 253 | * **init(id)** 254 | sets element as a jsPlumb draggable 255 | 256 | * **plumbIt(id)** 257 | Called when adding steps to a workflow, in turn it calls the addEndpoint and init functions. 258 | 259 | * **unPlumbIt(id)** 260 | Called when deleting steps, it removes all the endpoint on a step of a given id. 261 | 262 | * **connectIt(result)** 263 | Called when loading a saved workflow. It programatically makes all the connections by looping through the results array (WorkFlowViewModel -> results()) and connecting via uuid's ("1_Right" -> "2_Left"). Also updates the connection overlay label with data from the results item (label, daysLater). 264 | 265 | 266 | 267 | [jQuery]:http://jquery.com 268 | [jQueryUI]:http://jqueryui.com 269 | [knockoutjs]:http://knockoutjs.com 270 | [knockout mapping plugin]:http://knockoutjs.com/documentation/plugins-mapping.html 271 | [jsPlumb]:http://jsplumbtoolkit.com/demo/home/dom.html 272 | [Twitter Bootstrap]:http://getbootstrap.com 273 | [Bootbox.js]:http://bootboxjs.com/ 274 | [jquery.cookie]:https://github.com/carhartl/jquery-cookie 275 | [jQuery contextMenu]:https://github.com/medialize/jQuery-contextMenu 276 | [Modernizr]:http://modernizr.com/ 277 | 278 | [connector]:http://jsplumbtoolkit.com/doc/connectors.html 279 | [endpoint]:http://jsplumbtoolkit.com/doc/endpoints.html 280 | [label]:http://jsplumbtoolkit.com/doc/overlays.html#type-label 281 | [draggable]:http://jsplumbtoolkit.com/doc/home.html#dragging 282 | 283 | [connection]:(http://jsplumbtoolkit.com/doc/connections.html#programmatic) 284 | -------------------------------------------------------------------------------- /css/jquery.contextMenu.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery contextMenu - Plugin for simple contextMenu handling 3 | * 4 | * Version: git-master 5 | * 6 | * Authors: Rodney Rehm, Addy Osmani (patches for FF) 7 | * Web: http://medialize.github.com/jQuery-contextMenu/ 8 | * 9 | * Licensed under 10 | * MIT License http://www.opensource.org/licenses/mit-license 11 | * GPL v3 http://opensource.org/licenses/GPL-3.0 12 | * 13 | */ 14 | 15 | .context-menu-list { 16 | margin:0; 17 | padding:0; 18 | 19 | min-width: 120px; 20 | max-width: 250px; 21 | display: inline-block; 22 | position: absolute; 23 | list-style-type: none; 24 | 25 | border: 1px solid #DDD; 26 | background: #EEE; 27 | 28 | -webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); 29 | -moz-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); 30 | -ms-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); 31 | -o-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); 32 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); 33 | 34 | font-family: Verdana, Arial, Helvetica, sans-serif; 35 | font-size: 11px; 36 | } 37 | 38 | .context-menu-item { 39 | padding: 2px 2px 2px 2px; 40 | background-color: #EEE; 41 | position: relative; 42 | -webkit-user-select: none; 43 | -moz-user-select: -moz-none; 44 | -ms-user-select: none; 45 | user-select: none; 46 | } 47 | 48 | .context-menu-separator { 49 | padding-bottom:0; 50 | border-bottom: 1px solid #DDD; 51 | } 52 | 53 | .context-menu-item > label > input, 54 | .context-menu-item > label > textarea { 55 | -webkit-user-select: text; 56 | -moz-user-select: text; 57 | -ms-user-select: text; 58 | user-select: text; 59 | } 60 | 61 | .context-menu-item.hover { 62 | cursor: pointer; 63 | background-color: #39F; 64 | } 65 | 66 | .context-menu-item.disabled { 67 | color: #666; 68 | } 69 | 70 | .context-menu-input.hover, 71 | .context-menu-item.disabled.hover { 72 | cursor: default; 73 | background-color: #EEE; 74 | } 75 | 76 | .context-menu-submenu:after { 77 | content: ">"; 78 | color: #666; 79 | position: absolute; 80 | top: 0; 81 | right: 3px; 82 | z-index: 1; 83 | } 84 | 85 | 86 | @font-face { 87 | font-family: 'Glyphicons Halflings'; 88 | src: url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/fonts/glyphicons-halflings-regular.eot'); 89 | src: url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/fonts/glyphicons-halflings-regular.woff') format('woff'), url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); 90 | } 91 | 92 | /* icons 93 | #protip: 94 | In case you want to use sprites for icons (which I would suggest you do) have a look at 95 | http://css-tricks.com/13224-pseudo-spriting/ to get an idea of how to implement 96 | .context-menu-item.icon:before {} 97 | */ 98 | 99 | .context-menu-item.icon:before { 100 | position: relative; 101 | top: 1px; 102 | display: inline-block; 103 | font-family: 'Glyphicons Halflings'; 104 | font-style: normal; 105 | font-weight: normal; 106 | line-height: 1; 107 | height:18px; 108 | width:18px; 109 | font-size:14px; 110 | 111 | -webkit-font-smoothing: antialiased; 112 | -moz-osx-font-smoothing: grayscale; 113 | } 114 | 115 | .context-menu-item.icon-glyphicon-earphone:before { content: "\e182"; } 116 | .context-menu-item.icon-glyphicon-envelope:before { content: "\2709"; } 117 | .context-menu-item.icon-glyphicon-calendar:before { content: "\e109"; } 118 | .context-menu-item.icon-glyphicon-globe:before { content: "\e135"; } 119 | .context-menu-item.icon-glyphicon-file:before { content: "\e022"; } 120 | 121 | /* vertically align inside labels */ 122 | .context-menu-input > label > * { vertical-align: top; } 123 | 124 | /* position checkboxes and radios as icons */ 125 | .context-menu-input > label > input[type="checkbox"], 126 | .context-menu-input > label > input[type="radio"] { 127 | margin-left: -17px; 128 | } 129 | .context-menu-input > label > span { 130 | margin-left: 5px; 131 | } 132 | 133 | .context-menu-input > label, 134 | .context-menu-input > label > input[type="text"], 135 | .context-menu-input > label > textarea, 136 | .context-menu-input > label > select { 137 | display: block; 138 | width: 100%; 139 | 140 | -webkit-box-sizing: border-box; 141 | -moz-box-sizing: border-box; 142 | -ms-box-sizing: border-box; 143 | -o-box-sizing: border-box; 144 | box-sizing: border-box; 145 | } 146 | 147 | .context-menu-input > label > textarea { 148 | height: 100px; 149 | } 150 | .context-menu-item > .context-menu-list { 151 | display: none; 152 | /* re-positioned by js */ 153 | right: -5px; 154 | top: 5px; 155 | } 156 | 157 | .context-menu-item.hover > .context-menu-list { 158 | display: block; 159 | } 160 | 161 | .context-menu-accesskey { 162 | text-decoration: underline; 163 | } 164 | 165 | .context-title:before { 166 | content: "Add a New Step:"; 167 | display: block; 168 | position: absolute; 169 | top: 0; 170 | right: 0; 171 | left: 0; 172 | background: #DDD; 173 | padding: 2px; 174 | 175 | font-family: Verdana, Arial, Helvetica, sans-serif; 176 | font-size: 11px; 177 | font-weight: bold; 178 | } 179 | .context-title :first-child { 180 | margin-top: 20px; 181 | } 182 | -------------------------------------------------------------------------------- /css/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | overflow-x: hidden; 4 | } 5 | 6 | body { 7 | background: #fff; 8 | } 9 | 10 | .ui-effects-transfer { 11 | border: 2px dotted #61b7cf; 12 | background: rgba(97, 183, 207, 0.15); 13 | z-index: 10001; 14 | } 15 | 16 | nav, aside, footer { 17 | background: #ddd; 18 | } 19 | 20 | nav { 21 | height: 200px; 22 | width: 200px; 23 | position: absolute; 24 | top: 50%; 25 | left: 50%; 26 | margin-top: -100px; 27 | margin-left: -100px; 28 | } 29 | 30 | aside { 31 | background: #f7f7f7; 32 | width: 250px; 33 | position: absolute; 34 | bottom: 51px; 35 | right: 0px; 36 | padding: 0; 37 | font-size: 12px; 38 | z-index: 998; 39 | box-shadow: -1px 0 10px 0 rgba(0, 0, 0, 0.35); 40 | } 41 | aside .table { 42 | background: #f7f7f7; 43 | margin: 0; 44 | } 45 | aside .table > tbody > tr > td, 46 | aside .table > thead > tr > th { 47 | border-color: #ccc; 48 | } 49 | aside .table > thead > tr > th { 50 | text-align: right; 51 | } 52 | aside .table > tbody > tr > td { 53 | padding: 0; 54 | } 55 | aside .table > tbody > tr > td.action { 56 | width: 30px; 57 | } 58 | aside .table > tbody > tr > td input { 59 | width: 100%; 60 | height: 30px; 61 | } 62 | aside .table > tbody > tr > td a, aside .table > tbody > tr > td span { 63 | display: block; 64 | height: 30px; 65 | width: 188px; 66 | white-space: nowrap; 67 | overflow: hidden; 68 | text-overflow: ellipsis; 69 | line-height: 30px; 70 | padding: 0 5px; 71 | } 72 | aside .table > tbody > tr > td span { 73 | background: #428bca; 74 | color: #fff; 75 | } 76 | aside .table > tbody > tr > td.action a { 77 | text-align: center; 78 | padding: 0; 79 | width: auto; 80 | } 81 | aside .table > tbody > tr > td.delete a { 82 | width: auto; 83 | font-size: 21px; 84 | font-weight: bold; 85 | color: #000; 86 | text-shadow: 0 1px 0 #fff; 87 | filter: alpha(opacity=20); 88 | opacity: .2; 89 | padding: 0; 90 | } 91 | aside .table > tbody > tr > td.delete a:hover { 92 | color: #000; 93 | text-decoration: none; 94 | cursor: pointer; 95 | filter: alpha(opacity=50); 96 | opacity: .5; 97 | } 98 | 99 | footer { 100 | position: fixed; 101 | z-index: 997; 102 | bottom: 0px; 103 | width: 100%; 104 | text-align: center; 105 | horizontal-align: middle; 106 | height: 50px; 107 | padding: 0; 108 | background: -webkit-linear-gradient( #d5d9dc, #bbbbbb) left repeat; 109 | background: linear-gradient( #d5d9dc, #bbbbbb) left repeat; 110 | border-top: solid 1px #bbc0c4; 111 | box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.35); 112 | } 113 | footer.dirty { 114 | background: -webkit-linear-gradient( #e7c7c7, #d9a6a6) left repeat; 115 | background: linear-gradient( #e7c7c7, #d9a6a6) left repeat; 116 | } 117 | footer .btn { 118 | margin-top: 7px; 119 | } 120 | footer #workflowBtn { 121 | margin: 0; 122 | border-radius: 0; 123 | height: 50px; 124 | width: 250px; 125 | } 126 | 127 | article { 128 | height: 100%; 129 | width: 100%; 130 | position: relative; 131 | overflow: hidden; 132 | } 133 | article .context { 134 | position: absolute; 135 | height: 100%; 136 | width: 100%; 137 | background: url(../img/graphpaper-background.png); 138 | } 139 | article .context h1 { 140 | margin: 10px; 141 | padding: 0; 142 | color: rgba(0, 0, 0, 0.25); 143 | } 144 | 145 | .step { 146 | height: 145px; 147 | position: absolute; 148 | width: 165px; 149 | } 150 | .step:hover { 151 | box-shadow: 0 2px 19px rgba(0, 0, 0, 0.25); 152 | cursor: grab; 153 | } 154 | .step:active { 155 | cursor: grabbing; 156 | } 157 | .step .step-inner { 158 | width: 100%; 159 | height: 145px; 160 | position: relative; 161 | } 162 | 163 | .panel-default { 164 | background: rgba(255, 255, 255, 0.75); 165 | border-color: #5d85a1; 166 | border-radius: 0; 167 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.15); 168 | } 169 | 170 | .panel-heading { 171 | height: 38px; 172 | border-radius: 0; 173 | padding: 0; 174 | } 175 | .panel-heading:after { 176 | content: ""; 177 | display: table; 178 | clear: both; 179 | } 180 | 181 | .panel-body { 182 | height: 105px; 183 | background: none; 184 | border-radius: 0; 185 | padding: 5px 10px; 186 | font-size: 11px; 187 | line-height: 14px; 188 | text-align: center; 189 | } 190 | .panel-body:after { 191 | content: ""; 192 | display: table; 193 | clear: both; 194 | } 195 | 196 | .result { 197 | position: absolute; 198 | height: 52px; 199 | width: 140px; 200 | margin-left: -65px; 201 | margin-top: -10px; 202 | background: #fff; 203 | border: 1px dotted gray; 204 | padding: 0.4em; 205 | font: bold 10px sans-serif; 206 | color: #000; 207 | z-index: 10000; 208 | text-align: center; 209 | box-shadow: 0 2px 19px rgba(0, 0, 0, 0.25); 210 | } 211 | .result .result-inner { 212 | position: relative; 213 | } 214 | .result .resultLabel { 215 | width: 110px; 216 | margin-bottom: 5px; 217 | } 218 | .result .resultDays { 219 | width: 25px; 220 | } 221 | 222 | .step-inner:hover .delete, .result-inner:hover .delete { 223 | opacity: 0.85; 224 | } 225 | .step-inner .delete, .result-inner .delete { 226 | border-radius: 100px; 227 | position: absolute; 228 | top: -8px; 229 | right: -8px; 230 | width: 20px; 231 | height: 20px; 232 | padding: 2px; 233 | line-height: 12px; 234 | opacity: 0; 235 | -webkit-transition: opacity 200ms ease-in-out; 236 | -moz-transition: opacity 200ms ease-in-out; 237 | transition: opacity 200ms ease-in-out; 238 | } 239 | .step-inner .delete.btn-default, .result-inner .delete.btn-default { 240 | border-color: #5d85a1; 241 | } 242 | .step-inner .delete:hover, .step-inner .delete:active, .result-inner .delete:hover, .result-inner .delete:active { 243 | opacity: 1.0; 244 | outline: none; 245 | } 246 | 247 | .result-inner .delete { 248 | top: -14px; 249 | right: -14px; 250 | } 251 | 252 | .editable { 253 | position: relative; 254 | } 255 | .editable select, .editable input, .editable textarea, .editable label, .editable .placeholder { 256 | position: absolute; 257 | z-index: 999; 258 | } 259 | 260 | .isStart { 261 | position: absolute; 262 | top: -17px; 263 | left: 0px; 264 | line-height: normal; 265 | height: 17px; 266 | width: 50px; 267 | } 268 | .isStart label { 269 | display: block; 270 | position: relative; 271 | height: 17px; 272 | overflow: hidden; 273 | text-align: center; 274 | color: #fff; 275 | background: rgba(0, 0, 0, 0.1); 276 | text-transform: uppercase; 277 | font-size: 10px; 278 | line-height: 17px; 279 | } 280 | .isStart label:after { 281 | content: "Start ?"; 282 | } 283 | .isStart label:hover { 284 | cursor: pointer; 285 | } 286 | .isStart label.active { 287 | background: #7ab02c; 288 | } 289 | .isStart label.active:after { 290 | content: "Start"; 291 | } 292 | .isStart label input { 293 | position: absolute; 294 | top: -100px; 295 | } 296 | 297 | .contact a, .name a, .desc a { 298 | display: block; 299 | color: #333; 300 | } 301 | .contact a:hover, .name a:hover, .desc a:hover { 302 | text-decoration: none; 303 | } 304 | 305 | .contact, .name { 306 | height: 37px; 307 | width: 37px; 308 | float: left; 309 | } 310 | 311 | .contact { 312 | border-right: solid 1px rgba(0, 0, 0, 0.13); 313 | } 314 | .contact a { 315 | font-size: 24px; 316 | text-align: center; 317 | line-height: 42px; 318 | } 319 | .contact select { 320 | left: -20px; 321 | } 322 | 323 | .name { 324 | width: 125px; 325 | border-left: solid 1px #fff; 326 | } 327 | .name a { 328 | font-size: 11px; 329 | line-height: 37px; 330 | text-indent: 5px; 331 | font-weight: bold; 332 | width: 120px; 333 | white-space: nowrap; 334 | overflow: hidden; 335 | text-overflow: ellipsis; 336 | } 337 | .name a:hover { 338 | cursor: text; 339 | } 340 | .name input { 341 | height: 31px; 342 | width: 200px; 343 | top: 3px; 344 | left: 3px; 345 | } 346 | 347 | .user { 348 | height: 16px; 349 | } 350 | .user a { 351 | font-weight: bold; 352 | } 353 | .user select { 354 | left: 15%; 355 | } 356 | 357 | .desc { 358 | height: 45px; 359 | margin: 4px 0; 360 | } 361 | .desc a { 362 | position: absolute; 363 | top: 0px; 364 | left: 0px; 365 | display: block; 366 | height: 45px; 367 | width: 100%; 368 | overflow: hidden; 369 | text-overflow: ellipsis; 370 | white-space: normal; 371 | z-index: 10; 372 | background: #fff; 373 | background: rgba(255, 255, 255, 0.35); 374 | font-weight: bold; 375 | text-align: center; 376 | } 377 | .desc a:hover { 378 | cursor: text; 379 | } 380 | .desc .placeholder { 381 | top: 0px; 382 | left: 0px; 383 | z-index: 0; 384 | color: #000; 385 | font-style: italic; 386 | display: block; 387 | text-align: center; 388 | width: 100%; 389 | background: #fff; 390 | } 391 | .desc textarea { 392 | width: 300px; 393 | height: 130px; 394 | left: 0px; 395 | } 396 | 397 | .overlay { 398 | position: absolute; 399 | z-index: 1005; 400 | height: 100%; 401 | width: 100%; 402 | background: rgba(0, 0, 0, 0.65); 403 | } 404 | 405 | .poppanel { 406 | position: absolute; 407 | z-index: 1010; 408 | top: 50%; 409 | left: 50%; 410 | padding: 1px; 411 | text-align: left; 412 | white-space: normal; 413 | background-color: #fff; 414 | background-clip: padding-box; 415 | border: 1px solid #ccc; 416 | border: 1px solid rgba(0, 0, 0, 0.2); 417 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 418 | } 419 | .poppanel .poppanel-title, .poppanel .poppanel-footer { 420 | padding: 8px 14px; 421 | margin: 0; 422 | font-size: 14px; 423 | font-weight: bold; 424 | line-height: 18px; 425 | background-color: #f7f7f7; 426 | border-bottom: 1px solid #ebebeb; 427 | } 428 | .poppanel .poppanel-footer { 429 | border-bottom: 0; 430 | border-top: 1px solid #ebebeb; 431 | } 432 | .poppanel .poppanel-footer:after { 433 | content: ""; 434 | display: table; 435 | clear: both; 436 | } 437 | .poppanel .poppanel-footer .btn { 438 | margin: 0; 439 | } 440 | .poppanel .poppanel-content { 441 | padding: 0; 442 | } 443 | .poppanel .poppanel-content:after { 444 | content: ""; 445 | display: table; 446 | clear: both; 447 | } 448 | .poppanel .poppanel-content .img { 449 | float: left; 450 | padding: 5px; 451 | width: 460px; 452 | text-align: center; 453 | } 454 | .poppanel .poppanel-content .img img { 455 | max-height: 300px; 456 | max-width: 450px; 457 | } 458 | .poppanel .poppanel-content .right { 459 | width: 200px; 460 | float: right; 461 | position: relative; 462 | border-left: solid 1px #ddd; 463 | } 464 | .poppanel .poppanel-content .right .list { 465 | height: 310px; 466 | overflow: auto; 467 | } 468 | .poppanel .poppanel-content .table > tbody > tr > td { 469 | padding: 0; 470 | line-height: normal; 471 | } 472 | .poppanel .poppanel-content .table > tbody > tr > td label { 473 | width: 150px; 474 | margin: 0; 475 | position: relative; 476 | display: block; 477 | padding: 5px; 478 | font-size: 11px; 479 | white-space: nowrap; 480 | overflow: hidden; 481 | text-overflow: ellipsis; 482 | } 483 | .poppanel .poppanel-content .table > tbody > tr > td label:hover { 484 | cursor: pointer; 485 | } 486 | .poppanel .poppanel-content .table > tbody > tr > td label input { 487 | position: absolute; 488 | left: -100px; 489 | } 490 | .poppanel .poppanel-content .table > tbody > tr > td label .glyphicon { 491 | min-width: 16px; 492 | } 493 | 494 | .flowchart-demo .aLabel { 495 | background-color: white; 496 | padding: 0.4em; 497 | font: bold 10px sans-serif; 498 | color: #000; 499 | z-index: 21; 500 | border: 1px dotted gray; 501 | opacity: 0.8; 502 | filter: alpha(opacity=80); 503 | cursor: pointer; 504 | text-align: center; 505 | } 506 | .flowchart-demo .aLabel._jsPlumb_hover { 507 | background-color: #5C96BC; 508 | color: white; 509 | border: 1px solid white; 510 | z-index: 10000; 511 | } 512 | .flowchart-demo ._jsPlumb_connector._jsPlumb_hover { 513 | z-index: 9999; 514 | } 515 | 516 | .key { 517 | display: none; 518 | height: 460px; 519 | width: 200px; 520 | position: fixed; 521 | top: 10px; 522 | right: 10px; 523 | background: rgba(255, 255, 255, 0.65); 524 | border: solid 1px #ddd; 525 | border-radius: 10px; 526 | box-shadow: 0 2px 19px rgba(0, 0, 0, 0.25); 527 | } 528 | .key .help { 529 | display: none; 530 | text-align: center; 531 | line-height: 26px; 532 | } 533 | .key .content { 534 | padding: 10px; 535 | } 536 | .key h2 { 537 | font-weight: bold; 538 | margin: 0 0 10px 0; 539 | font-size: 14px; 540 | line-height: 20px; 541 | border-bottom: solid 1px #ddd; 542 | } 543 | .key p { 544 | font-size: 11px; 545 | line-height: 13px; 546 | margin-bottom: 10px; 547 | } 548 | .key label { 549 | display: block; 550 | position: relative; 551 | height: 20px; 552 | padding-left: 30px; 553 | font-size: 11px; 554 | } 555 | .key label svg, .key label img { 556 | position: absolute; 557 | top: 50%; 558 | left: 0px; 559 | margin-top: -12px; 560 | } 561 | .key.collapsed { 562 | height: 26px; 563 | width: 26px; 564 | } 565 | .key.collapsed .help { 566 | display: block; 567 | } 568 | .key.collapsed .content { 569 | display: none; 570 | } 571 | -------------------------------------------------------------------------------- /img/example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x2gboye/workflow/3fe6349414859ed9bb95394839b495758b0551fc/img/example.PNG -------------------------------------------------------------------------------- /img/example2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x2gboye/workflow/3fe6349414859ed9bb95394839b495758b0551fc/img/example2.png -------------------------------------------------------------------------------- /img/graphpaper-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x2gboye/workflow/3fe6349414859ed9bb95394839b495758b0551fc/img/graphpaper-background.png -------------------------------------------------------------------------------- /img/pointer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x2gboye/workflow/3fe6349414859ed9bb95394839b495758b0551fc/img/pointer.png -------------------------------------------------------------------------------- /img/textEdit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x2gboye/workflow/3fe6349414859ed9bb95394839b495758b0551fc/img/textEdit.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Workflow 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

18 |
19 | 20 |
21 | 22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 | 32 |
33 |
34 | 36 | 37 | 38 | 39 | 48 |
49 |
50 | 53 | 60 |
61 | 62 |
63 |
64 |
65 | 68 | 77 |
78 |
79 | 80 | Description... 81 | 82 | 85 | 86 | 87 | 94 |
95 |
96 | 97 |
98 |
99 |
100 | 101 |
102 |
103 | 104 |
105 | 106 | 107 | 108 |
109 | 110 |
113 |
114 | 120 | days later 125 |    126 | OK 127 | 128 |
129 |
130 | 131 |
132 | 133 | 134 | 135 | 136 | 137 | 176 | 177 | 178 | 179 |
180 | 181 |
182 |

Directions

183 |

Right Click on the grid background to add a new step.

184 |

Click or Double Click (see key) on details for each step to edit it.

185 |

Click on any connection to edit its overlay text.

186 |

Workflows just get saved to your browser's local storage. So deleting your browsers stored content will delete all saved workflows. Also, workflows you save to one browser will not be available in other browsers.

187 |

Key

188 | 196 | 204 | 208 | 212 |

Download

213 | GitHub Repository 214 |
215 |
216 | 217 |
218 | 219 | 251 | 252 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | -------------------------------------------------------------------------------- /js/demo.data.js: -------------------------------------------------------------------------------- 1 | 2 | var demoData = { 3 | 4 | contactTypes: [ 5 | {Id: 1, name:"Call", icon:"glyphicon-earphone"}, 6 | {Id: 2, name:"Mailing", icon:"glyphicon-envelope"}, 7 | {Id: 3, name:"Appointment", icon:"glyphicon-calendar"}, 8 | {Id: 4, name:"Email", icon:"glyphicon-globe"}, 9 | {Id: 5, name:"Other", icon:"glyphicon-file"} 10 | ], 11 | 12 | userTypes: [ 13 | {Id: 1, name:"All Agency Users"}, 14 | {Id: 2, name:"Full Time"}, 15 | {Id: 3, name:"Agent - Full Time"} 16 | ], 17 | 18 | templates: [ 19 | {Id:1, name:"Save More"}, 20 | {Id:2, name:"Protect Your Business"}, 21 | {Id:3, name:"Optional Auto"}, 22 | {Id:4, name:"Don't Miss Out"}, 23 | {Id:5, name:"Family's Insurance Needs"}, 24 | {Id:6, name:"Own Your Own Business"}, 25 | {Id:7, name:"Protect Your Family"}, 26 | {Id:8, name:"Essential Life"}, 27 | {Id:9, name:"Things Change"}, 28 | {Id:10, name:"We're with you all the way"} 29 | ] 30 | 31 | }; 32 | 33 | 34 | -------------------------------------------------------------------------------- /js/demo.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | // Create chart view model 5 | var workflowViewModel = new WorkflowViewModel(); 6 | 7 | /** 8 | * Initializes view model and apply bindings 9 | */ 10 | function initializeDemoData(demoData) { 11 | 12 | // Add Templates to chart 13 | if (demoData.templates) { 14 | for (var i = 0; i < demoData.templates.length; i++) { 15 | var template = demoData.templates[i], 16 | templateViewModel = new TemplateViewModel(workflowViewModel, template.Id, template.name); 17 | 18 | workflowViewModel.templates.push(templateViewModel); 19 | } 20 | } 21 | 22 | 23 | if (Modernizr.localstorage) { 24 | // Add Saved data to workflow list 25 | for (var i = 0; i < localStorage.length; i++) { 26 | var key = localStorage.key(i); 27 | 28 | if(key.substr(0, key.indexOf(".")) === "workflow") { 29 | var value = JSON.parse(localStorage[key]), 30 | listViewModel = new ListViewModel(workflowViewModel, key, value.name); 31 | workflowViewModel.allWorkflows.push(listViewModel); 32 | } 33 | } 34 | } 35 | // Initialize chart view model 36 | workflowViewModel.initialize(); 37 | 38 | // Bind view model to view 39 | ko.applyBindings(workflowViewModel); 40 | } 41 | 42 | initializeDemoData(demoData); 43 | 44 | })(); -------------------------------------------------------------------------------- /js/ko.extensions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom KnockOut handler: handles key press event, when user presses Enter key 3 | */ 4 | ko.bindingHandlers.enterPress = { 5 | init: function (element, valueAccessor, allBindingsAccessor, viewModel) { 6 | var allBindings = allBindingsAccessor(); 7 | element.addEventListener('keydown', function (event) { 8 | var keyCode = (event.which ? event.which : event.keyCode); 9 | if (keyCode === 13) { 10 | allBindings.enterPress.call(viewModel); 11 | return false; 12 | } 13 | return true; 14 | }); 15 | } 16 | }; 17 | 18 | /** 19 | * Custom KnockOut handler: handles key press event, when user presses Escape key 20 | */ 21 | ko.bindingHandlers.escPress = { 22 | init: function (element, valueAccessor, allBindingsAccessor, viewModel) { 23 | var allBindings = allBindingsAccessor(); 24 | element.addEventListener('keydown', function (event) { 25 | var keyCode = (event.which ? event.which : event.keyCode); 26 | if (keyCode === 27) { 27 | allBindings.escPress.call(viewModel); 28 | return false; 29 | } 30 | return true; 31 | }); 32 | } 33 | }; 34 | 35 | /** 36 | * Custom knockout extender: required field validation 37 | */ 38 | ko.extenders.required = function (target) { 39 | // add some sub-observables to our observable 40 | target.hasError = ko.observable(); 41 | 42 | // define a function to do validation 43 | function validate(newValue) { 44 | target.hasError(newValue ? false : true); 45 | } 46 | 47 | // initial validation 48 | validate(target()); 49 | 50 | // validate whenever the value changes 51 | target.subscribe(validate); 52 | 53 | // return the original observable 54 | return target; 55 | }; 56 | 57 | /** 58 | * Custom knockout extender: required whole number 59 | */ 60 | ko.extenders.integer = function(target) { 61 | //create a writeable computed property to intercept writes to our observable 62 | var result = ko.computed({ 63 | read: target, 64 | write: function(newValue) { 65 | var current = target(), 66 | valueToWrite = Math.round(isNaN(newValue) ? 0 : newValue); 67 | 68 | if (valueToWrite !== current) { 69 | target(valueToWrite); 70 | } else { 71 | //if the rounded value is the same, but a different value was written, force a notification for the current field to pick it up 72 | if (newValue != current) { 73 | target.notifySubscribers(valueToWrite); 74 | } 75 | } 76 | } 77 | }).extend({ notify: "always" }); 78 | 79 | //initialize with current value to make sure it is rounded appropriately 80 | result(target()); 81 | 82 | //return the new computed property 83 | return result; 84 | }; -------------------------------------------------------------------------------- /js/lib/_notes/dwsync.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /js/lib/jquery.contextMenu.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery contextMenu - Plugin for simple contextMenu handling 3 | * 4 | * Version: git-master 5 | * 6 | * Authors: Rodney Rehm, Addy Osmani (patches for FF) 7 | * Web: http://medialize.github.com/jQuery-contextMenu/ 8 | * 9 | * Licensed under 10 | * MIT License http://www.opensource.org/licenses/mit-license 11 | * GPL v3 http://opensource.org/licenses/GPL-3.0 12 | * 13 | */ 14 | 15 | (function($, undefined){ 16 | 17 | // TODO: - 18 | // ARIA stuff: menuitem, menuitemcheckbox und menuitemradio 19 | // create structure if $.support[htmlCommand || htmlMenuitem] and !opt.disableNative 20 | 21 | // determine html5 compatibility 22 | $.support.htmlMenuitem = ('HTMLMenuItemElement' in window); 23 | $.support.htmlCommand = ('HTMLCommandElement' in window); 24 | $.support.eventSelectstart = ("onselectstart" in document.documentElement); 25 | /* // should the need arise, test for css user-select 26 | $.support.cssUserSelect = (function(){ 27 | var t = false, 28 | e = document.createElement('div'); 29 | 30 | $.each('Moz|Webkit|Khtml|O|ms|Icab|'.split('|'), function(i, prefix) { 31 | var propCC = prefix + (prefix ? 'U' : 'u') + 'serSelect', 32 | prop = (prefix ? ('-' + prefix.toLowerCase() + '-') : '') + 'user-select'; 33 | 34 | e.style.cssText = prop + ': text;'; 35 | if (e.style[propCC] == 'text') { 36 | t = true; 37 | return false; 38 | } 39 | 40 | return true; 41 | }); 42 | 43 | return t; 44 | })(); 45 | */ 46 | 47 | if (!$.ui || !$.ui.widget) { 48 | // duck punch $.cleanData like jQueryUI does to get that remove event 49 | // https://github.com/jquery/jquery-ui/blob/master/ui/jquery.ui.widget.js#L16-24 50 | var _cleanData = $.cleanData; 51 | $.cleanData = function( elems ) { 52 | for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { 53 | try { 54 | $( elem ).triggerHandler( "remove" ); 55 | // http://bugs.jquery.com/ticket/8235 56 | } catch( e ) {} 57 | } 58 | _cleanData( elems ); 59 | }; 60 | } 61 | 62 | var // currently active contextMenu trigger 63 | $currentTrigger = null, 64 | // is contextMenu initialized with at least one menu? 65 | initialized = false, 66 | // window handle 67 | $win = $(window), 68 | // number of registered menus 69 | counter = 0, 70 | // mapping selector to namespace 71 | namespaces = {}, 72 | // mapping namespace to options 73 | menus = {}, 74 | // custom command type handlers 75 | types = {}, 76 | // default values 77 | defaults = { 78 | // selector of contextMenu trigger 79 | selector: null, 80 | // where to append the menu to 81 | appendTo: null, 82 | // method to trigger context menu ["right", "left", "hover"] 83 | trigger: "right", 84 | // hide menu when mouse leaves trigger / menu elements 85 | autoHide: false, 86 | // ms to wait before showing a hover-triggered context menu 87 | delay: 200, 88 | // flag denoting if a second trigger should simply move (true) or rebuild (false) an open menu 89 | // as long as the trigger happened on one of the trigger-element's child nodes 90 | reposition: true, 91 | // determine position to show menu at 92 | determinePosition: function($menu) { 93 | // position to the lower middle of the trigger element 94 | if ($.ui && $.ui.position) { 95 | // .position() is provided as a jQuery UI utility 96 | // (...and it won't work on hidden elements) 97 | $menu.css('display', 'block').position({ 98 | my: "center top", 99 | at: "center bottom", 100 | of: this, 101 | offset: "0 5", 102 | collision: "fit" 103 | }).css('display', 'none'); 104 | } else { 105 | // determine contextMenu position 106 | var offset = this.offset(); 107 | offset.top += this.outerHeight(); 108 | offset.left += this.outerWidth() / 2 - $menu.outerWidth() / 2; 109 | $menu.css(offset); 110 | } 111 | }, 112 | // position menu 113 | position: function(opt, x, y) { 114 | var $this = this, 115 | offset; 116 | // determine contextMenu position 117 | if (!x && !y) { 118 | opt.determinePosition.call(this, opt.$menu); 119 | return; 120 | } else if (x === "maintain" && y === "maintain") { 121 | // x and y must not be changed (after re-show on command click) 122 | offset = opt.$menu.position(); 123 | } else { 124 | // x and y are given (by mouse event) 125 | offset = {top: y, left: x}; 126 | } 127 | 128 | // correct offset if viewport demands it 129 | var bottom = $win.scrollTop() + $win.height(), 130 | right = $win.scrollLeft() + $win.width(), 131 | height = opt.$menu.height(), 132 | width = opt.$menu.width(); 133 | 134 | if (offset.top + height > bottom) { 135 | offset.top -= height; 136 | } 137 | 138 | if (offset.left + width > right) { 139 | offset.left -= width; 140 | } 141 | 142 | opt.$menu.css(offset); 143 | }, 144 | // position the sub-menu 145 | positionSubmenu: function($menu) { 146 | if ($.ui && $.ui.position) { 147 | // .position() is provided as a jQuery UI utility 148 | // (...and it won't work on hidden elements) 149 | $menu.css('display', 'block').position({ 150 | my: "left top", 151 | at: "right top", 152 | of: this, 153 | collision: "flipfit fit" 154 | }).css('display', ''); 155 | } else { 156 | // determine contextMenu position 157 | var offset = { 158 | top: 0, 159 | left: this.outerWidth() 160 | }; 161 | $menu.css(offset); 162 | } 163 | }, 164 | // offset to add to zIndex 165 | zIndex: 1, 166 | // show hide animation settings 167 | animation: { 168 | duration: 50, 169 | show: 'slideDown', 170 | hide: 'slideUp' 171 | }, 172 | // events 173 | events: { 174 | show: $.noop, 175 | hide: $.noop 176 | }, 177 | // default callback 178 | callback: null, 179 | // list of contextMenu items 180 | items: {} 181 | }, 182 | // mouse position for hover activation 183 | hoveract = { 184 | timer: null, 185 | pageX: null, 186 | pageY: null 187 | }, 188 | // determine zIndex 189 | zindex = function($t) { 190 | var zin = 0, 191 | $tt = $t; 192 | 193 | while (true) { 194 | zin = Math.max(zin, parseInt($tt.css('z-index'), 10) || 0); 195 | $tt = $tt.parent(); 196 | if (!$tt || !$tt.length || "html body".indexOf($tt.prop('nodeName').toLowerCase()) > -1 ) { 197 | break; 198 | } 199 | } 200 | 201 | return zin; 202 | }, 203 | // event handlers 204 | handle = { 205 | // abort anything 206 | abortevent: function(e){ 207 | e.preventDefault(); 208 | e.stopImmediatePropagation(); 209 | }, 210 | 211 | // contextmenu show dispatcher 212 | contextmenu: function(e) { 213 | var $this = $(this); 214 | 215 | // disable actual context-menu 216 | e.preventDefault(); 217 | e.stopImmediatePropagation(); 218 | 219 | // abort native-triggered events unless we're triggering on right click 220 | if (e.data.trigger != 'right' && e.originalEvent) { 221 | return; 222 | } 223 | 224 | // abort event if menu is visible for this trigger 225 | if ($this.hasClass('context-menu-active')) { 226 | return; 227 | } 228 | 229 | if (!$this.hasClass('context-menu-disabled')) { 230 | // theoretically need to fire a show event at 231 | // http://www.whatwg.org/specs/web-apps/current-work/multipage/interactive-elements.html#context-menus 232 | // var evt = jQuery.Event("show", { data: data, pageX: e.pageX, pageY: e.pageY, relatedTarget: this }); 233 | // e.data.$menu.trigger(evt); 234 | 235 | $currentTrigger = $this; 236 | if (e.data.build) { 237 | var built = e.data.build($currentTrigger, e); 238 | // abort if build() returned false 239 | if (built === false) { 240 | return; 241 | } 242 | 243 | // dynamically build menu on invocation 244 | e.data = $.extend(true, {}, defaults, e.data, built || {}); 245 | 246 | // abort if there are no items to display 247 | if (!e.data.items || $.isEmptyObject(e.data.items)) { 248 | // Note: jQuery captures and ignores errors from event handlers 249 | if (window.console) { 250 | (console.error || console.log)("No items specified to show in contextMenu"); 251 | } 252 | 253 | throw new Error('No Items specified'); 254 | } 255 | 256 | // backreference for custom command type creation 257 | e.data.$trigger = $currentTrigger; 258 | 259 | op.create(e.data); 260 | } 261 | // show menu 262 | op.show.call($this, e.data, e.pageX, e.pageY); 263 | } 264 | }, 265 | // contextMenu left-click trigger 266 | click: function(e) { 267 | e.preventDefault(); 268 | e.stopImmediatePropagation(); 269 | $(this).trigger($.Event("contextmenu", { data: e.data, pageX: e.pageX, pageY: e.pageY })); 270 | }, 271 | // contextMenu right-click trigger 272 | mousedown: function(e) { 273 | // register mouse down 274 | var $this = $(this); 275 | 276 | // hide any previous menus 277 | if ($currentTrigger && $currentTrigger.length && !$currentTrigger.is($this)) { 278 | $currentTrigger.data('contextMenu').$menu.trigger('contextmenu:hide'); 279 | } 280 | 281 | // activate on right click 282 | if (e.button == 2) { 283 | $currentTrigger = $this.data('contextMenuActive', true); 284 | } 285 | }, 286 | // contextMenu right-click trigger 287 | mouseup: function(e) { 288 | // show menu 289 | var $this = $(this); 290 | if ($this.data('contextMenuActive') && $currentTrigger && $currentTrigger.length && $currentTrigger.is($this) && !$this.hasClass('context-menu-disabled')) { 291 | e.preventDefault(); 292 | e.stopImmediatePropagation(); 293 | $currentTrigger = $this; 294 | $this.trigger($.Event("contextmenu", { data: e.data, pageX: e.pageX, pageY: e.pageY })); 295 | } 296 | 297 | $this.removeData('contextMenuActive'); 298 | }, 299 | // contextMenu hover trigger 300 | mouseenter: function(e) { 301 | var $this = $(this), 302 | $related = $(e.relatedTarget), 303 | $document = $(document); 304 | 305 | // abort if we're coming from a menu 306 | if ($related.is('.context-menu-list') || $related.closest('.context-menu-list').length) { 307 | return; 308 | } 309 | 310 | // abort if a menu is shown 311 | if ($currentTrigger && $currentTrigger.length) { 312 | return; 313 | } 314 | 315 | hoveract.pageX = e.pageX; 316 | hoveract.pageY = e.pageY; 317 | hoveract.data = e.data; 318 | $document.on('mousemove.contextMenuShow', handle.mousemove); 319 | hoveract.timer = setTimeout(function() { 320 | hoveract.timer = null; 321 | $document.off('mousemove.contextMenuShow'); 322 | $currentTrigger = $this; 323 | $this.trigger($.Event("contextmenu", { data: hoveract.data, pageX: hoveract.pageX, pageY: hoveract.pageY })); 324 | }, e.data.delay ); 325 | }, 326 | // contextMenu hover trigger 327 | mousemove: function(e) { 328 | hoveract.pageX = e.pageX; 329 | hoveract.pageY = e.pageY; 330 | }, 331 | // contextMenu hover trigger 332 | mouseleave: function(e) { 333 | // abort if we're leaving for a menu 334 | var $related = $(e.relatedTarget); 335 | if ($related.is('.context-menu-list') || $related.closest('.context-menu-list').length) { 336 | return; 337 | } 338 | 339 | try { 340 | clearTimeout(hoveract.timer); 341 | } catch(e) {} 342 | 343 | hoveract.timer = null; 344 | }, 345 | 346 | // click on layer to hide contextMenu 347 | layerClick: function(e) { 348 | var $this = $(this), 349 | root = $this.data('contextMenuRoot'), 350 | mouseup = false, 351 | button = e.button, 352 | x = e.pageX, 353 | y = e.pageY, 354 | target, 355 | offset, 356 | selectors; 357 | 358 | e.preventDefault(); 359 | e.stopImmediatePropagation(); 360 | 361 | setTimeout(function() { 362 | var $window, hideshow, possibleTarget; 363 | var triggerAction = ((root.trigger == 'left' && button === 0) || (root.trigger == 'right' && button === 2)); 364 | 365 | // find the element that would've been clicked, wasn't the layer in the way 366 | if (document.elementFromPoint) { 367 | root.$layer.hide(); 368 | target = document.elementFromPoint(x - $win.scrollLeft(), y - $win.scrollTop()); 369 | root.$layer.show(); 370 | } 371 | 372 | if (root.reposition && triggerAction) { 373 | if (document.elementFromPoint) { 374 | if (root.$trigger.is(target) || root.$trigger.has(target).length) { 375 | root.position.call(root.$trigger, root, x, y); 376 | return; 377 | } 378 | } else { 379 | offset = root.$trigger.offset(); 380 | $window = $(window); 381 | // while this looks kinda awful, it's the best way to avoid 382 | // unnecessarily calculating any positions 383 | offset.top += $window.scrollTop(); 384 | if (offset.top <= e.pageY) { 385 | offset.left += $window.scrollLeft(); 386 | if (offset.left <= e.pageX) { 387 | offset.bottom = offset.top + root.$trigger.outerHeight(); 388 | if (offset.bottom >= e.pageY) { 389 | offset.right = offset.left + root.$trigger.outerWidth(); 390 | if (offset.right >= e.pageX) { 391 | // reposition 392 | root.position.call(root.$trigger, root, x, y); 393 | return; 394 | } 395 | } 396 | } 397 | } 398 | } 399 | } 400 | 401 | if (target && triggerAction) { 402 | root.$trigger.one('contextmenu:hidden', function() { 403 | $(target).contextMenu({x: x, y: y}); 404 | }); 405 | } 406 | 407 | root.$menu.trigger('contextmenu:hide'); 408 | }, 50); 409 | }, 410 | // key handled :hover 411 | keyStop: function(e, opt) { 412 | if (!opt.isInput) { 413 | e.preventDefault(); 414 | } 415 | 416 | e.stopPropagation(); 417 | }, 418 | key: function(e) { 419 | var opt = $currentTrigger.data('contextMenu') || {}; 420 | 421 | switch (e.keyCode) { 422 | case 9: 423 | case 38: // up 424 | handle.keyStop(e, opt); 425 | // if keyCode is [38 (up)] or [9 (tab) with shift] 426 | if (opt.isInput) { 427 | if (e.keyCode == 9 && e.shiftKey) { 428 | e.preventDefault(); 429 | opt.$selected && opt.$selected.find('input, textarea, select').blur(); 430 | opt.$menu.trigger('prevcommand'); 431 | return; 432 | } else if (e.keyCode == 38 && opt.$selected.find('input, textarea, select').prop('type') == 'checkbox') { 433 | // checkboxes don't capture this key 434 | e.preventDefault(); 435 | return; 436 | } 437 | } else if (e.keyCode != 9 || e.shiftKey) { 438 | opt.$menu.trigger('prevcommand'); 439 | return; 440 | } 441 | // omitting break; 442 | 443 | // case 9: // tab - reached through omitted break; 444 | case 40: // down 445 | handle.keyStop(e, opt); 446 | if (opt.isInput) { 447 | if (e.keyCode == 9) { 448 | e.preventDefault(); 449 | opt.$selected && opt.$selected.find('input, textarea, select').blur(); 450 | opt.$menu.trigger('nextcommand'); 451 | return; 452 | } else if (e.keyCode == 40 && opt.$selected.find('input, textarea, select').prop('type') == 'checkbox') { 453 | // checkboxes don't capture this key 454 | e.preventDefault(); 455 | return; 456 | } 457 | } else { 458 | opt.$menu.trigger('nextcommand'); 459 | return; 460 | } 461 | break; 462 | 463 | case 37: // left 464 | handle.keyStop(e, opt); 465 | if (opt.isInput || !opt.$selected || !opt.$selected.length) { 466 | break; 467 | } 468 | 469 | if (!opt.$selected.parent().hasClass('context-menu-root')) { 470 | var $parent = opt.$selected.parent().parent(); 471 | opt.$selected.trigger('contextmenu:blur'); 472 | opt.$selected = $parent; 473 | return; 474 | } 475 | break; 476 | 477 | case 39: // right 478 | handle.keyStop(e, opt); 479 | if (opt.isInput || !opt.$selected || !opt.$selected.length) { 480 | break; 481 | } 482 | 483 | var itemdata = opt.$selected.data('contextMenu') || {}; 484 | if (itemdata.$menu && opt.$selected.hasClass('context-menu-submenu')) { 485 | opt.$selected = null; 486 | itemdata.$selected = null; 487 | itemdata.$menu.trigger('nextcommand'); 488 | return; 489 | } 490 | break; 491 | 492 | case 35: // end 493 | case 36: // home 494 | if (opt.$selected && opt.$selected.find('input, textarea, select').length) { 495 | return; 496 | } else { 497 | (opt.$selected && opt.$selected.parent() || opt.$menu) 498 | .children(':not(.disabled, .not-selectable)')[e.keyCode == 36 ? 'first' : 'last']() 499 | .trigger('contextmenu:focus'); 500 | e.preventDefault(); 501 | return; 502 | } 503 | break; 504 | 505 | case 13: // enter 506 | handle.keyStop(e, opt); 507 | if (opt.isInput) { 508 | if (opt.$selected && !opt.$selected.is('textarea, select')) { 509 | e.preventDefault(); 510 | return; 511 | } 512 | break; 513 | } 514 | opt.$selected && opt.$selected.trigger('mouseup'); 515 | return; 516 | 517 | case 32: // space 518 | case 33: // page up 519 | case 34: // page down 520 | // prevent browser from scrolling down while menu is visible 521 | handle.keyStop(e, opt); 522 | return; 523 | 524 | case 27: // esc 525 | handle.keyStop(e, opt); 526 | opt.$menu.trigger('contextmenu:hide'); 527 | return; 528 | 529 | default: // 0-9, a-z 530 | var k = (String.fromCharCode(e.keyCode)).toUpperCase(); 531 | if (opt.accesskeys[k]) { 532 | // according to the specs accesskeys must be invoked immediately 533 | opt.accesskeys[k].$node.trigger(opt.accesskeys[k].$menu 534 | ? 'contextmenu:focus' 535 | : 'mouseup' 536 | ); 537 | return; 538 | } 539 | break; 540 | } 541 | // pass event to selected item, 542 | // stop propagation to avoid endless recursion 543 | e.stopPropagation(); 544 | opt.$selected && opt.$selected.trigger(e); 545 | }, 546 | 547 | // select previous possible command in menu 548 | prevItem: function(e) { 549 | e.stopPropagation(); 550 | var opt = $(this).data('contextMenu') || {}; 551 | 552 | // obtain currently selected menu 553 | if (opt.$selected) { 554 | var $s = opt.$selected; 555 | opt = opt.$selected.parent().data('contextMenu') || {}; 556 | opt.$selected = $s; 557 | } 558 | 559 | var $children = opt.$menu.children(), 560 | $prev = !opt.$selected || !opt.$selected.prev().length ? $children.last() : opt.$selected.prev(), 561 | $round = $prev; 562 | 563 | // skip disabled 564 | while ($prev.hasClass('disabled') || $prev.hasClass('not-selectable')) { 565 | if ($prev.prev().length) { 566 | $prev = $prev.prev(); 567 | } else { 568 | $prev = $children.last(); 569 | } 570 | if ($prev.is($round)) { 571 | // break endless loop 572 | return; 573 | } 574 | } 575 | 576 | // leave current 577 | if (opt.$selected) { 578 | handle.itemMouseleave.call(opt.$selected.get(0), e); 579 | } 580 | 581 | // activate next 582 | handle.itemMouseenter.call($prev.get(0), e); 583 | 584 | // focus input 585 | var $input = $prev.find('input, textarea, select'); 586 | if ($input.length) { 587 | $input.focus(); 588 | } 589 | }, 590 | // select next possible command in menu 591 | nextItem: function(e) { 592 | e.stopPropagation(); 593 | var opt = $(this).data('contextMenu') || {}; 594 | 595 | // obtain currently selected menu 596 | if (opt.$selected) { 597 | var $s = opt.$selected; 598 | opt = opt.$selected.parent().data('contextMenu') || {}; 599 | opt.$selected = $s; 600 | } 601 | 602 | var $children = opt.$menu.children(), 603 | $next = !opt.$selected || !opt.$selected.next().length ? $children.first() : opt.$selected.next(), 604 | $round = $next; 605 | 606 | // skip disabled 607 | while ($next.hasClass('disabled') || $next.hasClass('not-selectable')) { 608 | if ($next.next().length) { 609 | $next = $next.next(); 610 | } else { 611 | $next = $children.first(); 612 | } 613 | if ($next.is($round)) { 614 | // break endless loop 615 | return; 616 | } 617 | } 618 | 619 | // leave current 620 | if (opt.$selected) { 621 | handle.itemMouseleave.call(opt.$selected.get(0), e); 622 | } 623 | 624 | // activate next 625 | handle.itemMouseenter.call($next.get(0), e); 626 | 627 | // focus input 628 | var $input = $next.find('input, textarea, select'); 629 | if ($input.length) { 630 | $input.focus(); 631 | } 632 | }, 633 | 634 | // flag that we're inside an input so the key handler can act accordingly 635 | focusInput: function(e) { 636 | var $this = $(this).closest('.context-menu-item'), 637 | data = $this.data(), 638 | opt = data.contextMenu, 639 | root = data.contextMenuRoot; 640 | 641 | root.$selected = opt.$selected = $this; 642 | root.isInput = opt.isInput = true; 643 | }, 644 | // flag that we're inside an input so the key handler can act accordingly 645 | blurInput: function(e) { 646 | var $this = $(this).closest('.context-menu-item'), 647 | data = $this.data(), 648 | opt = data.contextMenu, 649 | root = data.contextMenuRoot; 650 | 651 | root.isInput = opt.isInput = false; 652 | }, 653 | 654 | // :hover on menu 655 | menuMouseenter: function(e) { 656 | var root = $(this).data().contextMenuRoot; 657 | root.hovering = true; 658 | }, 659 | // :hover on menu 660 | menuMouseleave: function(e) { 661 | var root = $(this).data().contextMenuRoot; 662 | if (root.$layer && root.$layer.is(e.relatedTarget)) { 663 | root.hovering = false; 664 | } 665 | }, 666 | 667 | // :hover done manually so key handling is possible 668 | itemMouseenter: function(e) { 669 | var $this = $(this), 670 | data = $this.data(), 671 | opt = data.contextMenu, 672 | root = data.contextMenuRoot; 673 | 674 | root.hovering = true; 675 | 676 | // abort if we're re-entering 677 | if (e && root.$layer && root.$layer.is(e.relatedTarget)) { 678 | e.preventDefault(); 679 | e.stopImmediatePropagation(); 680 | } 681 | 682 | // make sure only one item is selected 683 | (opt.$menu ? opt : root).$menu 684 | .children('.hover').trigger('contextmenu:blur'); 685 | 686 | if ($this.hasClass('disabled') || $this.hasClass('not-selectable')) { 687 | opt.$selected = null; 688 | return; 689 | } 690 | 691 | $this.trigger('contextmenu:focus'); 692 | }, 693 | // :hover done manually so key handling is possible 694 | itemMouseleave: function(e) { 695 | var $this = $(this), 696 | data = $this.data(), 697 | opt = data.contextMenu, 698 | root = data.contextMenuRoot; 699 | 700 | if (root !== opt && root.$layer && root.$layer.is(e.relatedTarget)) { 701 | root.$selected && root.$selected.trigger('contextmenu:blur'); 702 | e.preventDefault(); 703 | e.stopImmediatePropagation(); 704 | root.$selected = opt.$selected = opt.$node; 705 | return; 706 | } 707 | 708 | $this.trigger('contextmenu:blur'); 709 | }, 710 | // contextMenu item click 711 | itemClick: function(e) { 712 | var $this = $(this), 713 | data = $this.data(), 714 | opt = data.contextMenu, 715 | root = data.contextMenuRoot, 716 | key = data.contextMenuKey, 717 | callback; 718 | 719 | // abort if the key is unknown or disabled or is a menu 720 | if (!opt.items[key] || $this.is('.disabled, .context-menu-submenu, .context-menu-separator, .not-selectable')) { 721 | return; 722 | } 723 | 724 | e.preventDefault(); 725 | e.stopImmediatePropagation(); 726 | 727 | if ($.isFunction(root.callbacks[key]) && Object.prototype.hasOwnProperty.call(root.callbacks, key)) { 728 | // item-specific callback 729 | callback = root.callbacks[key]; 730 | } else if ($.isFunction(root.callback)) { 731 | // default callback 732 | callback = root.callback; 733 | } else { 734 | // no callback, no action 735 | return; 736 | } 737 | 738 | // hide menu if callback doesn't stop that 739 | if (callback.call(root.$trigger, key, root) !== false) { 740 | root.$menu.trigger('contextmenu:hide'); 741 | } else if (root.$menu.parent().length) { 742 | op.update.call(root.$trigger, root); 743 | } 744 | }, 745 | // ignore click events on input elements 746 | inputClick: function(e) { 747 | e.stopImmediatePropagation(); 748 | }, 749 | 750 | // hide 751 | hideMenu: function(e, data) { 752 | var root = $(this).data('contextMenuRoot'); 753 | op.hide.call(root.$trigger, root, data && data.force); 754 | }, 755 | // focus 756 | focusItem: function(e) { 757 | e.stopPropagation(); 758 | var $this = $(this), 759 | data = $this.data(), 760 | opt = data.contextMenu, 761 | root = data.contextMenuRoot; 762 | 763 | $this.addClass('hover') 764 | .siblings('.hover').trigger('contextmenu:blur'); 765 | 766 | // remember selected 767 | opt.$selected = root.$selected = $this; 768 | 769 | // position sub-menu - do after show so dumb $.ui.position can keep up 770 | if (opt.$node) { 771 | root.positionSubmenu.call(opt.$node, opt.$menu); 772 | } 773 | }, 774 | // blur 775 | blurItem: function(e) { 776 | e.stopPropagation(); 777 | var $this = $(this), 778 | data = $this.data(), 779 | opt = data.contextMenu, 780 | root = data.contextMenuRoot; 781 | 782 | $this.removeClass('hover'); 783 | opt.$selected = null; 784 | } 785 | }, 786 | // operations 787 | op = { 788 | show: function(opt, x, y) { 789 | var $trigger = $(this), 790 | offset, 791 | css = {}; 792 | 793 | // hide any open menus 794 | $('#context-menu-layer').trigger('mousedown'); 795 | 796 | // backreference for callbacks 797 | opt.$trigger = $trigger; 798 | 799 | // show event 800 | if (opt.events.show.call($trigger, opt) === false) { 801 | $currentTrigger = null; 802 | return; 803 | } 804 | 805 | // create or update context menu 806 | op.update.call($trigger, opt); 807 | 808 | // position menu 809 | opt.position.call($trigger, opt, x, y); 810 | 811 | // make sure we're in front 812 | if (opt.zIndex) { 813 | css.zIndex = zindex($trigger) + opt.zIndex; 814 | } 815 | 816 | // add layer 817 | op.layer.call(opt.$menu, opt, css.zIndex); 818 | 819 | // adjust sub-menu zIndexes 820 | opt.$menu.find('ul').css('zIndex', css.zIndex + 1); 821 | 822 | // position and show context menu 823 | opt.$menu.css( css )[opt.animation.show](opt.animation.duration, function() { 824 | $trigger.trigger('contextmenu:visible'); 825 | }); 826 | // make options available and set state 827 | $trigger 828 | .data('contextMenu', opt) 829 | .addClass("context-menu-active"); 830 | 831 | // register key handler 832 | $(document).off('keydown.contextMenu').on('keydown.contextMenu', handle.key); 833 | // register autoHide handler 834 | if (opt.autoHide) { 835 | // mouse position handler 836 | $(document).on('mousemove.contextMenuAutoHide', function(e) { 837 | // need to capture the offset on mousemove, 838 | // since the page might've been scrolled since activation 839 | var pos = $trigger.offset(); 840 | pos.right = pos.left + $trigger.outerWidth(); 841 | pos.bottom = pos.top + $trigger.outerHeight(); 842 | 843 | if (opt.$layer && !opt.hovering && (!(e.pageX >= pos.left && e.pageX <= pos.right) || !(e.pageY >= pos.top && e.pageY <= pos.bottom))) { 844 | // if mouse in menu... 845 | opt.$menu.trigger('contextmenu:hide'); 846 | } 847 | }); 848 | } 849 | }, 850 | hide: function(opt, force) { 851 | var $trigger = $(this); 852 | if (!opt) { 853 | opt = $trigger.data('contextMenu') || {}; 854 | } 855 | 856 | // hide event 857 | if (!force && opt.events && opt.events.hide.call($trigger, opt) === false) { 858 | return; 859 | } 860 | 861 | // remove options and revert state 862 | $trigger 863 | .removeData('contextMenu') 864 | .removeClass("context-menu-active"); 865 | 866 | if (opt.$layer) { 867 | // keep layer for a bit so the contextmenu event can be aborted properly by opera 868 | setTimeout((function($layer) { 869 | return function(){ 870 | $layer.remove(); 871 | }; 872 | })(opt.$layer), 10); 873 | 874 | try { 875 | delete opt.$layer; 876 | } catch(e) { 877 | opt.$layer = null; 878 | } 879 | } 880 | 881 | // remove handle 882 | $currentTrigger = null; 883 | // remove selected 884 | opt.$menu.find('.hover').trigger('contextmenu:blur'); 885 | opt.$selected = null; 886 | // unregister key and mouse handlers 887 | //$(document).off('.contextMenuAutoHide keydown.contextMenu'); // http://bugs.jquery.com/ticket/10705 888 | $(document).off('.contextMenuAutoHide').off('keydown.contextMenu'); 889 | // hide menu 890 | opt.$menu && opt.$menu[opt.animation.hide](opt.animation.duration, function (){ 891 | // tear down dynamically built menu after animation is completed. 892 | if (opt.build) { 893 | opt.$menu.remove(); 894 | $.each(opt, function(key, value) { 895 | switch (key) { 896 | case 'ns': 897 | case 'selector': 898 | case 'build': 899 | case 'trigger': 900 | return true; 901 | 902 | default: 903 | opt[key] = undefined; 904 | try { 905 | delete opt[key]; 906 | } catch (e) {} 907 | return true; 908 | } 909 | }); 910 | } 911 | 912 | setTimeout(function() { 913 | $trigger.trigger('contextmenu:hidden'); 914 | }, 10); 915 | }); 916 | }, 917 | create: function(opt, root) { 918 | if (root === undefined) { 919 | root = opt; 920 | } 921 | // create contextMenu 922 | opt.$menu = $('
    ').addClass(opt.className || "").data({ 923 | 'contextMenu': opt, 924 | 'contextMenuRoot': root 925 | }); 926 | 927 | $.each(['callbacks', 'commands', 'inputs'], function(i,k){ 928 | opt[k] = {}; 929 | if (!root[k]) { 930 | root[k] = {}; 931 | } 932 | }); 933 | 934 | root.accesskeys || (root.accesskeys = {}); 935 | 936 | // create contextMenu items 937 | $.each(opt.items, function(key, item){ 938 | var $t = $('
  • ').addClass(item.className || ""), 939 | $label = null, 940 | $input = null; 941 | 942 | // iOS needs to see a click-event bound to an element to actually 943 | // have the TouchEvents infrastructure trigger the click event 944 | $t.on('click', $.noop); 945 | 946 | item.$node = $t.data({ 947 | 'contextMenu': opt, 948 | 'contextMenuRoot': root, 949 | 'contextMenuKey': key 950 | }); 951 | 952 | // register accesskey 953 | // NOTE: the accesskey attribute should be applicable to any element, but Safari5 and Chrome13 still can't do that 954 | if (item.accesskey) { 955 | var aks = splitAccesskey(item.accesskey); 956 | for (var i=0, ak; ak = aks[i]; i++) { 957 | if (!root.accesskeys[ak]) { 958 | root.accesskeys[ak] = item; 959 | item._name = item.name.replace(new RegExp('(' + ak + ')', 'i'), '$1'); 960 | break; 961 | } 962 | } 963 | } 964 | 965 | if (typeof item == "string") { 966 | $t.addClass('context-menu-separator not-selectable'); 967 | } else if (item.type && types[item.type]) { 968 | // run custom type handler 969 | types[item.type].call($t, item, opt, root); 970 | // register commands 971 | $.each([opt, root], function(i,k){ 972 | k.commands[key] = item; 973 | if ($.isFunction(item.callback)) { 974 | k.callbacks[key] = item.callback; 975 | } 976 | }); 977 | } else { 978 | // add label for input 979 | if (item.type == 'html') { 980 | $t.addClass('context-menu-html not-selectable'); 981 | } else if (item.type) { 982 | $label = $('').appendTo($t); 983 | $('').html(item._name || item.name).appendTo($label); 984 | $t.addClass('context-menu-input'); 985 | opt.hasTypes = true; 986 | $.each([opt, root], function(i,k){ 987 | k.commands[key] = item; 988 | k.inputs[key] = item; 989 | }); 990 | } else if (item.items) { 991 | item.type = 'sub'; 992 | } 993 | 994 | switch (item.type) { 995 | case 'text': 996 | $input = $('') 997 | .attr('name', 'context-menu-input-' + key) 998 | .val(item.value || "") 999 | .appendTo($label); 1000 | break; 1001 | 1002 | case 'textarea': 1003 | $input = $('') 1004 | .attr('name', 'context-menu-input-' + key) 1005 | .val(item.value || "") 1006 | .appendTo($label); 1007 | 1008 | if (item.height) { 1009 | $input.height(item.height); 1010 | } 1011 | break; 1012 | 1013 | case 'checkbox': 1014 | $input = $('') 1015 | .attr('name', 'context-menu-input-' + key) 1016 | .val(item.value || "") 1017 | .prop("checked", !!item.selected) 1018 | .prependTo($label); 1019 | break; 1020 | 1021 | case 'radio': 1022 | $input = $('') 1023 | .attr('name', 'context-menu-input-' + item.radio) 1024 | .val(item.value || "") 1025 | .prop("checked", !!item.selected) 1026 | .prependTo($label); 1027 | break; 1028 | 1029 | case 'select': 1030 | $input = $(' 1067 | if (item.type && item.type != 'sub' && item.type != 'html') { 1068 | $input 1069 | .on('focus', handle.focusInput) 1070 | .on('blur', handle.blurInput); 1071 | 1072 | if (item.events) { 1073 | $input.on(item.events, opt); 1074 | } 1075 | } 1076 | 1077 | // add icons 1078 | if (item.icon) { 1079 | $t.addClass("icon icon-" + item.icon); 1080 | } 1081 | } 1082 | 1083 | // cache contained elements 1084 | item.$input = $input; 1085 | item.$label = $label; 1086 | 1087 | // attach item to menu 1088 | $t.appendTo(opt.$menu); 1089 | 1090 | // Disable text selection 1091 | if (!opt.hasTypes && $.support.eventSelectstart) { 1092 | // browsers support user-select: none, 1093 | // IE has a special event for text-selection 1094 | // browsers supporting neither will not be preventing text-selection 1095 | $t.on('selectstart.disableTextSelect', handle.abortevent); 1096 | } 1097 | }); 1098 | // attach contextMenu to (to bypass any possible overflow:hidden issues on parents of the trigger element) 1099 | if (!opt.$node) { 1100 | opt.$menu.css('display', 'none').addClass('context-menu-root'); 1101 | } 1102 | opt.$menu.appendTo(opt.appendTo || document.body); 1103 | }, 1104 | resize: function($menu, nested) { 1105 | // determine widths of submenus, as CSS won't grow them automatically 1106 | // position:absolute within position:absolute; min-width:100; max-width:200; results in width: 100; 1107 | // kinda sucks hard... 1108 | 1109 | // determine width of absolutely positioned element 1110 | $menu.css({position: 'absolute', display: 'block'}); 1111 | // don't apply yet, because that would break nested elements' widths 1112 | // add a pixel to circumvent word-break issue in IE9 - #80 1113 | $menu.data('width', Math.ceil($menu.width()) + 1); 1114 | // reset styles so they allow nested elements to grow/shrink naturally 1115 | $menu.css({ 1116 | position: 'static', 1117 | minWidth: '0px', 1118 | maxWidth: '100000px' 1119 | }); 1120 | // identify width of nested menus 1121 | $menu.find('> li > ul').each(function() { 1122 | op.resize($(this), true); 1123 | }); 1124 | // reset and apply changes in the end because nested 1125 | // elements' widths wouldn't be calculatable otherwise 1126 | if (!nested) { 1127 | $menu.find('ul').andSelf().css({ 1128 | position: '', 1129 | display: '', 1130 | minWidth: '', 1131 | maxWidth: '' 1132 | }).width(function() { 1133 | return $(this).data('width'); 1134 | }); 1135 | } 1136 | }, 1137 | update: function(opt, root) { 1138 | var $trigger = this; 1139 | if (root === undefined) { 1140 | root = opt; 1141 | op.resize(opt.$menu); 1142 | } 1143 | // re-check disabled for each item 1144 | opt.$menu.children().each(function(){ 1145 | var $item = $(this), 1146 | key = $item.data('contextMenuKey'), 1147 | item = opt.items[key], 1148 | disabled = ($.isFunction(item.disabled) && item.disabled.call($trigger, key, root)) || item.disabled === true; 1149 | 1150 | // dis- / enable item 1151 | $item[disabled ? 'addClass' : 'removeClass']('disabled'); 1152 | 1153 | if (item.type) { 1154 | // dis- / enable input elements 1155 | $item.find('input, select, textarea').prop('disabled', disabled); 1156 | 1157 | // update input states 1158 | switch (item.type) { 1159 | case 'text': 1160 | case 'textarea': 1161 | item.$input.val(item.value || ""); 1162 | break; 1163 | 1164 | case 'checkbox': 1165 | case 'radio': 1166 | item.$input.val(item.value || "").prop('checked', !!item.selected); 1167 | break; 1168 | 1169 | case 'select': 1170 | item.$input.val(item.selected || ""); 1171 | break; 1172 | } 1173 | } 1174 | 1175 | if (item.$menu) { 1176 | // update sub-menu 1177 | op.update.call($trigger, item, root); 1178 | } 1179 | }); 1180 | }, 1181 | layer: function(opt, zIndex) { 1182 | // add transparent layer for click area 1183 | // filter and background for Internet Explorer, Issue #23 1184 | var $layer = opt.$layer = $('
    ') 1185 | .css({height: $win.height(), width: $win.width(), display: 'block'}) 1186 | .data('contextMenuRoot', opt) 1187 | .insertBefore(this) 1188 | .on('contextmenu', handle.abortevent) 1189 | .on('mousedown', handle.layerClick); 1190 | 1191 | // IE6 doesn't know position:fixed; 1192 | if (!$.support.fixedPosition) { 1193 | $layer.css({ 1194 | 'position' : 'absolute', 1195 | 'height' : $(document).height() 1196 | }); 1197 | } 1198 | 1199 | return $layer; 1200 | } 1201 | }; 1202 | 1203 | // split accesskey according to http://www.whatwg.org/specs/web-apps/current-work/multipage/editing.html#assigned-access-key 1204 | function splitAccesskey(val) { 1205 | var t = val.split(/\s+/), 1206 | keys = []; 1207 | 1208 | for (var i=0, k; k = t[i]; i++) { 1209 | k = k[0].toUpperCase(); // first character only 1210 | // theoretically non-accessible characters should be ignored, but different systems, different keyboard layouts, ... screw it. 1211 | // a map to look up already used access keys would be nice 1212 | keys.push(k); 1213 | } 1214 | 1215 | return keys; 1216 | } 1217 | 1218 | // handle contextMenu triggers 1219 | $.fn.contextMenu = function(operation) { 1220 | if (operation === undefined) { 1221 | this.first().trigger('contextmenu'); 1222 | } else if (operation.x && operation.y) { 1223 | this.first().trigger($.Event("contextmenu", {pageX: operation.x, pageY: operation.y})); 1224 | } else if (operation === "hide") { 1225 | var $menu = this.data('contextMenu').$menu; 1226 | $menu && $menu.trigger('contextmenu:hide'); 1227 | } else if (operation === "destroy") { 1228 | $.contextMenu("destroy", {context: this}); 1229 | } else if ($.isPlainObject(operation)) { 1230 | operation.context = this; 1231 | $.contextMenu("create", operation); 1232 | } else if (operation) { 1233 | this.removeClass('context-menu-disabled'); 1234 | } else if (!operation) { 1235 | this.addClass('context-menu-disabled'); 1236 | } 1237 | 1238 | return this; 1239 | }; 1240 | 1241 | // manage contextMenu instances 1242 | $.contextMenu = function(operation, options) { 1243 | if (typeof operation != 'string') { 1244 | options = operation; 1245 | operation = 'create'; 1246 | } 1247 | 1248 | if (typeof options == 'string') { 1249 | options = {selector: options}; 1250 | } else if (options === undefined) { 1251 | options = {}; 1252 | } 1253 | 1254 | // merge with default options 1255 | var o = $.extend(true, {}, defaults, options || {}); 1256 | var $document = $(document); 1257 | var $context = $document; 1258 | var _hasContext = false; 1259 | 1260 | if (!o.context || !o.context.length) { 1261 | o.context = document; 1262 | } else { 1263 | // you never know what they throw at you... 1264 | $context = $(o.context).first(); 1265 | o.context = $context.get(0); 1266 | _hasContext = o.context !== document; 1267 | } 1268 | 1269 | switch (operation) { 1270 | case 'create': 1271 | // no selector no joy 1272 | if (!o.selector) { 1273 | throw new Error('No selector specified'); 1274 | } 1275 | // make sure internal classes are not bound to 1276 | if (o.selector.match(/.context-menu-(list|item|input)($|\s)/)) { 1277 | throw new Error('Cannot bind to selector "' + o.selector + '" as it contains a reserved className'); 1278 | } 1279 | if (!o.build && (!o.items || $.isEmptyObject(o.items))) { 1280 | throw new Error('No Items specified'); 1281 | } 1282 | counter ++; 1283 | o.ns = '.contextMenu' + counter; 1284 | if (!_hasContext) { 1285 | namespaces[o.selector] = o.ns; 1286 | } 1287 | menus[o.ns] = o; 1288 | 1289 | // default to right click 1290 | if (!o.trigger) { 1291 | o.trigger = 'right'; 1292 | } 1293 | 1294 | if (!initialized) { 1295 | // make sure item click is registered first 1296 | $document 1297 | .on({ 1298 | 'contextmenu:hide.contextMenu': handle.hideMenu, 1299 | 'prevcommand.contextMenu': handle.prevItem, 1300 | 'nextcommand.contextMenu': handle.nextItem, 1301 | 'contextmenu.contextMenu': handle.abortevent, 1302 | 'mouseenter.contextMenu': handle.menuMouseenter, 1303 | 'mouseleave.contextMenu': handle.menuMouseleave 1304 | }, '.context-menu-list') 1305 | .on('mouseup.contextMenu', '.context-menu-input', handle.inputClick) 1306 | .on({ 1307 | 'mouseup.contextMenu': handle.itemClick, 1308 | 'contextmenu:focus.contextMenu': handle.focusItem, 1309 | 'contextmenu:blur.contextMenu': handle.blurItem, 1310 | 'contextmenu.contextMenu': handle.abortevent, 1311 | 'mouseenter.contextMenu': handle.itemMouseenter, 1312 | 'mouseleave.contextMenu': handle.itemMouseleave 1313 | }, '.context-menu-item'); 1314 | 1315 | initialized = true; 1316 | } 1317 | 1318 | // engage native contextmenu event 1319 | $context 1320 | .on('contextmenu' + o.ns, o.selector, o, handle.contextmenu); 1321 | 1322 | if (_hasContext) { 1323 | // add remove hook, just in case 1324 | $context.on('remove' + o.ns, function() { 1325 | $(this).contextMenu("destroy"); 1326 | }); 1327 | } 1328 | 1329 | switch (o.trigger) { 1330 | case 'hover': 1331 | $context 1332 | .on('mouseenter' + o.ns, o.selector, o, handle.mouseenter) 1333 | .on('mouseleave' + o.ns, o.selector, o, handle.mouseleave); 1334 | break; 1335 | 1336 | case 'left': 1337 | $context.on('click' + o.ns, o.selector, o, handle.click); 1338 | break; 1339 | /* 1340 | default: 1341 | // http://www.quirksmode.org/dom/events/contextmenu.html 1342 | $document 1343 | .on('mousedown' + o.ns, o.selector, o, handle.mousedown) 1344 | .on('mouseup' + o.ns, o.selector, o, handle.mouseup); 1345 | break; 1346 | */ 1347 | } 1348 | 1349 | // create menu 1350 | if (!o.build) { 1351 | op.create(o); 1352 | } 1353 | break; 1354 | 1355 | case 'destroy': 1356 | var $visibleMenu; 1357 | if (_hasContext) { 1358 | // get proper options 1359 | var context = o.context; 1360 | $.each(menus, function(ns, o) { 1361 | if (o.context !== context) { 1362 | return true; 1363 | } 1364 | 1365 | $visibleMenu = $('.context-menu-list').filter(':visible'); 1366 | if ($visibleMenu.length && $visibleMenu.data().contextMenuRoot.$trigger.is($(o.context).find(o.selector))) { 1367 | $visibleMenu.trigger('contextmenu:hide', {force: true}); 1368 | } 1369 | 1370 | try { 1371 | if (menus[o.ns].$menu) { 1372 | menus[o.ns].$menu.remove(); 1373 | } 1374 | 1375 | delete menus[o.ns]; 1376 | } catch(e) { 1377 | menus[o.ns] = null; 1378 | } 1379 | 1380 | $(o.context).off(o.ns); 1381 | 1382 | return true; 1383 | }); 1384 | } else if (!o.selector) { 1385 | $document.off('.contextMenu .contextMenuAutoHide'); 1386 | $.each(menus, function(ns, o) { 1387 | $(o.context).off(o.ns); 1388 | }); 1389 | 1390 | namespaces = {}; 1391 | menus = {}; 1392 | counter = 0; 1393 | initialized = false; 1394 | 1395 | $('#context-menu-layer, .context-menu-list').remove(); 1396 | } else if (namespaces[o.selector]) { 1397 | $visibleMenu = $('.context-menu-list').filter(':visible'); 1398 | if ($visibleMenu.length && $visibleMenu.data().contextMenuRoot.$trigger.is(o.selector)) { 1399 | $visibleMenu.trigger('contextmenu:hide', {force: true}); 1400 | } 1401 | 1402 | try { 1403 | if (menus[namespaces[o.selector]].$menu) { 1404 | menus[namespaces[o.selector]].$menu.remove(); 1405 | } 1406 | 1407 | delete menus[namespaces[o.selector]]; 1408 | } catch(e) { 1409 | menus[namespaces[o.selector]] = null; 1410 | } 1411 | 1412 | $document.off(namespaces[o.selector]); 1413 | } 1414 | break; 1415 | 1416 | case 'html5': 1417 | // if or are not handled by the browser, 1418 | // or options was a bool true, 1419 | // initialize $.contextMenu for them 1420 | if ((!$.support.htmlCommand && !$.support.htmlMenuitem) || (typeof options == "boolean" && options)) { 1421 | $('menu[type="context"]').each(function() { 1422 | if (this.id) { 1423 | $.contextMenu({ 1424 | selector: '[contextmenu=' + this.id +']', 1425 | items: $.contextMenu.fromMenu(this) 1426 | }); 1427 | } 1428 | }).css('display', 'none'); 1429 | } 1430 | break; 1431 | 1432 | default: 1433 | throw new Error('Unknown operation "' + operation + '"'); 1434 | } 1435 | 1436 | return this; 1437 | }; 1438 | 1439 | // import values into commands 1440 | $.contextMenu.setInputValues = function(opt, data) { 1441 | if (data === undefined) { 1442 | data = {}; 1443 | } 1444 | 1445 | $.each(opt.inputs, function(key, item) { 1446 | switch (item.type) { 1447 | case 'text': 1448 | case 'textarea': 1449 | item.value = data[key] || ""; 1450 | break; 1451 | 1452 | case 'checkbox': 1453 | item.selected = data[key] ? true : false; 1454 | break; 1455 | 1456 | case 'radio': 1457 | item.selected = (data[item.radio] || "") == item.value ? true : false; 1458 | break; 1459 | 1460 | case 'select': 1461 | item.selected = data[key] || ""; 1462 | break; 1463 | } 1464 | }); 1465 | }; 1466 | 1467 | // export values from commands 1468 | $.contextMenu.getInputValues = function(opt, data) { 1469 | if (data === undefined) { 1470 | data = {}; 1471 | } 1472 | 1473 | $.each(opt.inputs, function(key, item) { 1474 | switch (item.type) { 1475 | case 'text': 1476 | case 'textarea': 1477 | case 'select': 1478 | data[key] = item.$input.val(); 1479 | break; 1480 | 1481 | case 'checkbox': 1482 | data[key] = item.$input.prop('checked'); 1483 | break; 1484 | 1485 | case 'radio': 1486 | if (item.$input.prop('checked')) { 1487 | data[item.radio] = item.value; 1488 | } 1489 | break; 1490 | } 1491 | }); 1492 | 1493 | return data; 1494 | }; 1495 | 1496 | // find