├── .babelrc ├── .csscomb.json ├── .csslintrc ├── .editorconfig ├── .eslintdevrc ├── .eslintrc ├── .flowconfig ├── .gitattributes ├── .gitignore ├── .idea ├── .name ├── codeStyleSettings.xml ├── compiler.xml ├── copyright │ └── profiles_settings.xml ├── gradle.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── jsLibraryMappings.xml ├── libraries │ └── ritzy_editor_node_modules.xml ├── misc.xml ├── modules.xml ├── runConfigurations │ ├── Debug_Frontend__gulp_server_.xml │ ├── NodeJS_Remote_Debug.xml │ ├── Spyjs.xml │ └── Unit_Tests.xml ├── scopes │ └── scope_settings.xml ├── uiDesigner.xml ├── vcs.xml └── watcherTasks.xml ├── .jscsrc ├── .npmignore ├── .travis.yml ├── AUTHORS ├── LICENSE.txt ├── README.adoc ├── build.gradle ├── compiler.js ├── docs ├── API.adoc ├── CONTRIBUTING.adoc ├── DESIGN.adoc ├── DEVELOPMENT.adoc ├── INSTALLATION.adoc └── images │ ├── char_ids.png │ └── char_ids.svg ├── extractapi.js ├── gradle.properties ├── gradle ├── gradle.iml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── gulpfile.js ├── karma.conf.js ├── nodew ├── npm-shrinkwrap.json ├── package.json ├── ritzy-editor.iml ├── src ├── assets │ └── fonts │ │ ├── OpenSans-Bold-Latin.ttf │ │ ├── OpenSans-BoldItalic-Latin.ttf │ │ ├── OpenSans-Italic-Latin.ttf │ │ ├── OpenSans-Regular-Latin.ttf │ │ └── README.adoc ├── client.js ├── components │ ├── Cursor.js │ ├── DebugEditor.js │ ├── Editor.js │ ├── EditorLine.js │ ├── EditorLineContent.js │ ├── SelectionOverlay.js │ ├── SharedCursorMixin.js │ ├── SwarmClientMixin.js │ ├── TextInput.js │ ├── TextReplicaMixin.js │ └── __tests__ │ │ └── Editor-test.js ├── core │ ├── CursorModel.js │ ├── CursorSet.js │ ├── EditorCommon.js │ ├── ReactUtils.js │ ├── RichText.js │ ├── TextFontMetrics.js │ ├── __tests__ │ │ ├── ReactUtils-test.js │ │ ├── RichText-test.js │ │ ├── htmlparser-testb.js │ │ ├── htmlwriter-test.js │ │ ├── textwriter-test.js │ │ └── tokenizer-test.js │ ├── alt.js │ ├── attributes.js │ ├── dom.js │ ├── htmlparser.js │ ├── htmlwriter.js │ ├── replica.js │ ├── swarmclient.js │ ├── swarmfactory.js │ ├── swarmserver.js │ ├── textwriter.js │ ├── tokenizer.js │ └── utils.js ├── flux │ ├── EditorActions.js │ └── EditorStore.js ├── ritzy.js ├── server.js ├── styles │ ├── default-skin.less │ └── internal.less ├── templates │ └── index.html ├── tests │ ├── karma-test-entry.js │ └── setupdom.js └── vendor │ └── swarm │ ├── RedisStorage.js │ └── RedisStorage.txt └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | // http://babeljs.io/docs/usage/experimental/ 2 | { 3 | "optional": ["es7.objectRestSpread"] 4 | } 5 | -------------------------------------------------------------------------------- /.csscomb.json: -------------------------------------------------------------------------------- 1 | { 2 | "always-semicolon": true, 3 | "block-indent": 2, 4 | "color-case": "lower", 5 | "color-shorthand": true, 6 | "element-case": "lower", 7 | "eof-newline": true, 8 | "leading-zero": false, 9 | "remove-empty-rulesets": true, 10 | "space-after-colon": 1, 11 | "space-after-combinator": 1, 12 | "space-before-selector-delimiter": 0, 13 | "space-between-declarations": "\n", 14 | "space-after-opening-brace": "\n", 15 | "space-before-closing-brace": "\n", 16 | "space-before-colon": 0, 17 | "space-before-combinator": 1, 18 | "space-before-opening-brace": 1, 19 | "strip-spaces": true, 20 | "unitless-zero": true, 21 | "vendor-prefix-align": true, 22 | "sort-order": [ 23 | [ 24 | "position", 25 | "top", 26 | "right", 27 | "bottom", 28 | "left", 29 | "z-index", 30 | "display", 31 | "float", 32 | "width", 33 | "min-width", 34 | "max-width", 35 | "height", 36 | "min-height", 37 | "max-height", 38 | "-webkit-box-sizing", 39 | "-moz-box-sizing", 40 | "box-sizing", 41 | "-webkit-appearance", 42 | "padding", 43 | "padding-top", 44 | "padding-right", 45 | "padding-bottom", 46 | "padding-left", 47 | "margin", 48 | "margin-top", 49 | "margin-right", 50 | "margin-bottom", 51 | "margin-left", 52 | "overflow", 53 | "overflow-x", 54 | "overflow-y", 55 | "-webkit-overflow-scrolling", 56 | "-ms-overflow-x", 57 | "-ms-overflow-y", 58 | "-ms-overflow-style", 59 | "clip", 60 | "clear", 61 | "font", 62 | "font-family", 63 | "font-size", 64 | "font-style", 65 | "font-weight", 66 | "font-variant", 67 | "font-size-adjust", 68 | "font-stretch", 69 | "font-effect", 70 | "font-emphasize", 71 | "font-emphasize-position", 72 | "font-emphasize-style", 73 | "font-smooth", 74 | "-webkit-hyphens", 75 | "-moz-hyphens", 76 | "hyphens", 77 | "line-height", 78 | "color", 79 | "text-align", 80 | "-webkit-text-align-last", 81 | "-moz-text-align-last", 82 | "-ms-text-align-last", 83 | "text-align-last", 84 | "text-emphasis", 85 | "text-emphasis-color", 86 | "text-emphasis-style", 87 | "text-emphasis-position", 88 | "text-decoration", 89 | "text-indent", 90 | "text-justify", 91 | "text-outline", 92 | "-ms-text-overflow", 93 | "text-overflow", 94 | "text-overflow-ellipsis", 95 | "text-overflow-mode", 96 | "text-shadow", 97 | "text-transform", 98 | "text-wrap", 99 | "-webkit-text-size-adjust", 100 | "-ms-text-size-adjust", 101 | "letter-spacing", 102 | "-ms-word-break", 103 | "word-break", 104 | "word-spacing", 105 | "-ms-word-wrap", 106 | "word-wrap", 107 | "-moz-tab-size", 108 | "-o-tab-size", 109 | "tab-size", 110 | "white-space", 111 | "vertical-align", 112 | "list-style", 113 | "list-style-position", 114 | "list-style-type", 115 | "list-style-image", 116 | "pointer-events", 117 | "-ms-touch-action", 118 | "touch-action", 119 | "cursor", 120 | "visibility", 121 | "zoom", 122 | "flex-direction", 123 | "flex-order", 124 | "flex-pack", 125 | "flex-align", 126 | "table-layout", 127 | "empty-cells", 128 | "caption-side", 129 | "border-spacing", 130 | "border-collapse", 131 | "content", 132 | "quotes", 133 | "counter-reset", 134 | "counter-increment", 135 | "resize", 136 | "-webkit-user-select", 137 | "-moz-user-select", 138 | "-ms-user-select", 139 | "-o-user-select", 140 | "user-select", 141 | "nav-index", 142 | "nav-up", 143 | "nav-right", 144 | "nav-down", 145 | "nav-left", 146 | "background", 147 | "background-color", 148 | "background-image", 149 | "-ms-filter:\\'progid:DXImageTransform.Microsoft.gradient", 150 | "filter:progid:DXImageTransform.Microsoft.gradient", 151 | "filter:progid:DXImageTransform.Microsoft.AlphaImageLoader", 152 | "filter", 153 | "background-repeat", 154 | "background-attachment", 155 | "background-position", 156 | "background-position-x", 157 | "background-position-y", 158 | "-webkit-background-clip", 159 | "-moz-background-clip", 160 | "background-clip", 161 | "background-origin", 162 | "-webkit-background-size", 163 | "-moz-background-size", 164 | "-o-background-size", 165 | "background-size", 166 | "border", 167 | "border-color", 168 | "border-style", 169 | "border-width", 170 | "border-top", 171 | "border-top-color", 172 | "border-top-style", 173 | "border-top-width", 174 | "border-right", 175 | "border-right-color", 176 | "border-right-style", 177 | "border-right-width", 178 | "border-bottom", 179 | "border-bottom-color", 180 | "border-bottom-style", 181 | "border-bottom-width", 182 | "border-left", 183 | "border-left-color", 184 | "border-left-style", 185 | "border-left-width", 186 | "border-radius", 187 | "border-top-left-radius", 188 | "border-top-right-radius", 189 | "border-bottom-right-radius", 190 | "border-bottom-left-radius", 191 | "-webkit-border-image", 192 | "-moz-border-image", 193 | "-o-border-image", 194 | "border-image", 195 | "-webkit-border-image-source", 196 | "-moz-border-image-source", 197 | "-o-border-image-source", 198 | "border-image-source", 199 | "-webkit-border-image-slice", 200 | "-moz-border-image-slice", 201 | "-o-border-image-slice", 202 | "border-image-slice", 203 | "-webkit-border-image-width", 204 | "-moz-border-image-width", 205 | "-o-border-image-width", 206 | "border-image-width", 207 | "-webkit-border-image-outset", 208 | "-moz-border-image-outset", 209 | "-o-border-image-outset", 210 | "border-image-outset", 211 | "-webkit-border-image-repeat", 212 | "-moz-border-image-repeat", 213 | "-o-border-image-repeat", 214 | "border-image-repeat", 215 | "outline", 216 | "outline-width", 217 | "outline-style", 218 | "outline-color", 219 | "outline-offset", 220 | "-webkit-box-shadow", 221 | "-moz-box-shadow", 222 | "box-shadow", 223 | "filter:progid:DXImageTransform.Microsoft.Alpha(Opacity", 224 | "-ms-filter:\\'progid:DXImageTransform.Microsoft.Alpha", 225 | "opacity", 226 | "-ms-interpolation-mode", 227 | "-webkit-transition", 228 | "-moz-transition", 229 | "-ms-transition", 230 | "-o-transition", 231 | "transition", 232 | "-webkit-transition-delay", 233 | "-moz-transition-delay", 234 | "-ms-transition-delay", 235 | "-o-transition-delay", 236 | "transition-delay", 237 | "-webkit-transition-timing-function", 238 | "-moz-transition-timing-function", 239 | "-ms-transition-timing-function", 240 | "-o-transition-timing-function", 241 | "transition-timing-function", 242 | "-webkit-transition-duration", 243 | "-moz-transition-duration", 244 | "-ms-transition-duration", 245 | "-o-transition-duration", 246 | "transition-duration", 247 | "-webkit-transition-property", 248 | "-moz-transition-property", 249 | "-ms-transition-property", 250 | "-o-transition-property", 251 | "transition-property", 252 | "-webkit-transform", 253 | "-moz-transform", 254 | "-ms-transform", 255 | "-o-transform", 256 | "transform", 257 | "-webkit-transform-origin", 258 | "-moz-transform-origin", 259 | "-ms-transform-origin", 260 | "-o-transform-origin", 261 | "transform-origin", 262 | "-webkit-animation", 263 | "-moz-animation", 264 | "-ms-animation", 265 | "-o-animation", 266 | "animation", 267 | "-webkit-animation-name", 268 | "-moz-animation-name", 269 | "-ms-animation-name", 270 | "-o-animation-name", 271 | "animation-name", 272 | "-webkit-animation-duration", 273 | "-moz-animation-duration", 274 | "-ms-animation-duration", 275 | "-o-animation-duration", 276 | "animation-duration", 277 | "-webkit-animation-play-state", 278 | "-moz-animation-play-state", 279 | "-ms-animation-play-state", 280 | "-o-animation-play-state", 281 | "animation-play-state", 282 | "-webkit-animation-timing-function", 283 | "-moz-animation-timing-function", 284 | "-ms-animation-timing-function", 285 | "-o-animation-timing-function", 286 | "animation-timing-function", 287 | "-webkit-animation-delay", 288 | "-moz-animation-delay", 289 | "-ms-animation-delay", 290 | "-o-animation-delay", 291 | "animation-delay", 292 | "-webkit-animation-iteration-count", 293 | "-moz-animation-iteration-count", 294 | "-ms-animation-iteration-count", 295 | "-o-animation-iteration-count", 296 | "animation-iteration-count", 297 | "-webkit-animation-direction", 298 | "-moz-animation-direction", 299 | "-ms-animation-direction", 300 | "-o-animation-direction", 301 | "animation-direction" 302 | ] 303 | ] 304 | } 305 | -------------------------------------------------------------------------------- /.csslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "adjoining-classes": false, 3 | "box-sizing": false, 4 | "box-model": false, 5 | "compatible-vendor-prefixes": false, 6 | "floats": false, 7 | "font-sizes": false, 8 | "gradients": false, 9 | "important": false, 10 | "known-properties": false, 11 | "outline-none": false, 12 | "qualified-headings": false, 13 | "regex-selectors": false, 14 | "shorthand": false, 15 | "text-indent": false, 16 | "unique-headings": false, 17 | "universal-selector": false, 18 | "unqualified-attributes": false 19 | } 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.bat] 15 | end_of_line = crlf 16 | -------------------------------------------------------------------------------- /.eslintdevrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "sourceType": "module", 6 | "rules": { 7 | // Strict mode 8 | "strict": [2, "global"], 9 | 10 | // Code style, 0=off, 1=warning, 2=error 11 | "indent": [1, 2], 12 | "quotes": [1, "single"], 13 | "curly": [1, "multi-line"], 14 | "semi": [1, "never"], 15 | "space-unary-ops": [1, { "words": true, "nonwords": false }], 16 | "no-var": [0] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // temporary until https://github.com/eslint/espree/issues/116 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "es6": true, 8 | "mocha": true 9 | }, 10 | "ecmaFeatures": { 11 | "modules": true, 12 | "jsx": true, 13 | "blockBindings": true 14 | }, 15 | "sourceType": "module", 16 | "globals": { 17 | "__DEV__": true, 18 | "__SERVER__": true 19 | }, 20 | "plugins": [ 21 | "react" 22 | ], 23 | "rules": { 24 | // Strict mode 25 | "strict": [2, "global"], 26 | 27 | // React (eslint-plugin-react) 28 | "react/jsx-no-undef": 1, 29 | "react/jsx-uses-react": 1, 30 | "react/jsx-uses-vars": 1, 31 | "react/no-did-mount-set-state": 1, 32 | "react/no-did-update-set-state": 1, 33 | "react/no-unknown-property": 1, 34 | "react/prop-types": 1, 35 | "react/react-in-jsx-scope": 1, 36 | "react/self-closing-comp": 1, 37 | "react/wrap-multilines": 1, 38 | 39 | // Code style, 0=off, 1=warning, 2=error 40 | "indent": [1, 2], 41 | "quotes": [1, "single"], 42 | "curly": [1, "multi-line"], 43 | "semi": [1, "never"], 44 | "space-unary-ops": [1, { "words": true, "nonwords": false }], 45 | "no-underscore-dangle": [0], // leading underscore is useful inside React components 46 | "no-var": [1] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/build 3 | .*/config 4 | .*/node_modules 5 | .*/gulpfile.js 6 | 7 | [include] 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.jar binary 2 | *.cmd eol=crlf 3 | *.bat eol=crlf 4 | *.sh eol=lf 5 | nodew eol=lf 6 | * text=auto 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.gitkeep 2 | *.iws 3 | # Exclude until http://youtrack.jetbrains.com/issue/IDEA-89905 is resolved 4 | .idea/encodings.xml 5 | # End temporary for IDEA-89905 6 | .idea/artifacts/ 7 | .idea/dictionaries/ 8 | .idea/tasks.xml 9 | .idea/workspace.xml 10 | .idea/qaplug_profiles.xml 11 | .gradle 12 | build/ 13 | gradle/ 14 | dist/ 15 | lib/ 16 | # Mac meta-data files 17 | __MACOSX/ 18 | .DS_Store/ 19 | # NodeJS 20 | node_modules/ 21 | npm-debug.log 22 | tmp 23 | .swarm 24 | README.md 25 | ritzy-*.tgz 26 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | ritzy-editor -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 13 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 31 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/libraries/ritzy_editor_node_modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Debug_Frontend__gulp_server_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/NodeJS_Remote_Debug.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Spyjs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Unit_Tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $PROJECT_DIR$ 5 | true 6 | 7 | bdd 8 | --compilers js:compiler.js ./src/**/*-test.js 9 | $PROJECT_DIR$/docs 10 | false 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/scopes/scope_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/uiDesigner.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "google", 3 | "disallowSpacesInAnonymousFunctionExpression": null, 4 | "validateLineBreaks": "LF", 5 | "validateIndentation": 2, 6 | "excludeFiles": ["build/**", "node_modules/**"], 7 | "esprima": "esprima-fb" 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !.gitkeep 2 | *.iws 3 | # Exclude until http://youtrack.jetbrains.com/issue/IDEA-89905 is resolved 4 | .idea/encodings.xml 5 | # End temporary for IDEA-89905 6 | .idea/artifacts/ 7 | .idea/dictionaries/ 8 | .idea/tasks.xml 9 | .idea/workspace.xml 10 | .idea/qaplug_profiles.xml 11 | .gradle 12 | build/ 13 | gradle/ 14 | lib/ 15 | # Mac meta-data files 16 | __MACOSX/ 17 | .DS_Store/ 18 | # NodeJS 19 | node_modules/ 20 | npm-debug.log 21 | tmp 22 | .swarm 23 | README.md 24 | ritzy-*.tgz 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - "0.12" 5 | matrix: 6 | allow_failures: 7 | - iojs 8 | - node_js: "0.10" 9 | notifications: 10 | webhooks: 11 | urls: 12 | - https://webhooks.gitter.im/e/7deaf239ac8afc5058d3 13 | on_success: change 14 | on_failure: always 15 | on_start: never 16 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # Add your name below, in alphabetical order, in the form: 2 | # Name (url) 3 | # (email and url are optional) 4 | # 5 | Raman Gupta (https://github.com/rocketraman) 6 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | //noinspection GroovyAssignabilityCheck 2 | plugins { 3 | id "com.moowork.node" version "0.10" 4 | id "com.moowork.gulp" version "0.10" 5 | } 6 | 7 | apply plugin: 'base' 8 | apply plugin: 'com.moowork.node' 9 | apply plugin: 'com.moowork.gulp' 10 | 11 | node { 12 | // Version of node to use. 13 | version = '0.12.7' 14 | npmVersion = '2.11.3' 15 | 16 | // If true, it will download node using above parameters. 17 | // If false, it will try to use globally installed node. 18 | download = "${node.download}" 19 | 20 | // Set the work directory for unpacking node 21 | workDir = file("${project.projectDir}/gradle/nodejs") 22 | } 23 | 24 | task gulp_build_release(type: GulpTask) { 25 | args = ["serve", "--release"] 26 | } 27 | 28 | defaultTasks 'gulp_default' 29 | 30 | task wrapper(type: Wrapper) { 31 | gradleVersion = '2.4' 32 | } 33 | -------------------------------------------------------------------------------- /compiler.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('babel/register') 4 | 5 | var noop = function(module, file) { 6 | module._compile('', file) 7 | } 8 | 9 | require.extensions['.css'] = noop 10 | require.extensions['.gif'] = noop 11 | require.extensions['.jpg'] = noop 12 | require.extensions['.less'] = noop 13 | require.extensions['.png'] = noop 14 | require.extensions['.svg'] = noop 15 | -------------------------------------------------------------------------------- /docs/API.adoc: -------------------------------------------------------------------------------- 1 | = Ritzy Editor Client API 2 | :sectanchors: 3 | 4 | [[contents]] 5 | == Contents 6 | 7 | The content of the editor can be retrieved by one of the following methods: 8 | 9 | ==== 10 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L261[Ritzy.getContents]:: 11 | Returns the contents of the editor as an array of Char objects. The Char object is from the 12 | underlying CRDT data store. 13 | ==== 14 | 15 | ==== 16 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L271[Ritzy.getContentsRich]:: 17 | Returns the contents of the editor in a rich text JSON format representing the rich text chunks and the 18 | associated attributes. 19 | ==== 20 | 21 | ==== 22 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L280[Ritzy.getContentsHtml]:: 23 | Returns the contents of the editor as HTML. 24 | ==== 25 | 26 | ==== 27 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L289[Ritzy.getContentsText]:: 28 | Returns the contents of the editor as plain text. 29 | ==== 30 | 31 | [[selection]] 32 | == Selection 33 | 34 | The current selection can be retrieved by one of the following methods: 35 | 36 | ==== 37 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L300[Ritzy.getSelection]:: 38 | Returns the contents of the current selection as an array of Char objects. The Char object is from the 39 | underlying CRDT data store. 40 | Returns the contents of the current selection in the underlying CRDT data storage format. 41 | ==== 42 | 43 | ==== 44 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L310[Ritzy.getSelectionRich]:: 45 | Returns the contents of the current selection in a rich text JSON format representing the rich text chunks 46 | and the associated attributes. 47 | ==== 48 | 49 | ==== 50 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L319[Ritzy.getSelectionHtml]:: 51 | Returns the contents of the current selection as HTML. 52 | ==== 53 | 54 | ==== 55 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L328[Ritzy.getSelectionText]:: 56 | Returns the contents of the current selection as plain text. 57 | ==== 58 | 59 | [[cursor]] 60 | == Cursors 61 | 62 | Information about local and remote cursors is available: 63 | 64 | ==== 65 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L342[Ritzy.getPosition]:: 66 | Returns the current position of the local cursor. The position is a character at which the cursor is placed 67 | (the point just after the character), and an eolStart attribute. The eolStart attribute is when the position is 68 | at the character at the end of a line (will generally be space if it is a soft break, and a newline if it is a 69 | hard break), if eolStart is false the cursor is at the end of the line with that character, and if eolStart is 70 | true, the cursor is at the start of the next line. This is necessary because both cursor positions represent 71 | the *same* character position. 72 | ==== 73 | 74 | ==== 75 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L352[Ritzy.getRemoteCursors]:: 76 | Gets all remote cursors currently active in the document. Each remote cursor has attributes such as name 77 | ('name'), the last update time ('ms'), and the remote position of the cursor. 78 | ==== 79 | 80 | [[events]] 81 | == Events 82 | 83 | The following methods can be used to register listeners for events: 84 | 85 | ==== 86 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L365[Ritzy.onPositionChange]:: 87 | Register a callback that executes when the cursor position changes. 88 | 89 | The underlying event emitter event name is 'position-change'. 90 | 91 | Parameters::: 92 | * cb Callback with the following parameters: 93 | ** position - The new position. 94 | ==== 95 | 96 | ==== 97 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L379[Ritzy.onSelectionChange]:: 98 | Register a callback that executes when the selection changes. This event will also be raised when an active 99 | selection is destroyed -- the selection parameter in the callback will be null in this case. 100 | 101 | The underlying event emitter event name is 'selection-change'. 102 | 103 | Parameters::: 104 | * cb Callback with the following parameters: 105 | ** selection - The new selection in native CRDT format. The left char of the selection is exclusive and the right char is inclusive. 106 | ==== 107 | 108 | ==== 109 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L391[Ritzy.onFocusGained]:: 110 | Register a callback that executes when the editor gains focus. 111 | 112 | The underlying event emitter event name is 'focus-gained'. 113 | 114 | Parameters::: 115 | * cb Callback with no parameters. 116 | ==== 117 | 118 | ==== 119 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L403[Ritzy.onFocusLost]:: 120 | Register a callback that executes when the editor loses focus (blur). 121 | 122 | The underlying event emitter event name is 'focus-lost'. 123 | 124 | Parameters::: 125 | * cb Callback with no parameters. 126 | ==== 127 | 128 | ==== 129 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L416[Ritzy.onRemoteCursorAdd]:: 130 | Register a callback that executes when a remote cursor is added. 131 | 132 | The underlying event emitter event name is 'remote-cursor-add'. 133 | 134 | Parameters::: 135 | * cb Callback with the following parameters: 136 | ** remoteCursor - The remote cursor that was added. 137 | ==== 138 | 139 | ==== 140 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L429[Ritzy.onRemoteCursorRemove]:: 141 | Register a callback that executes when a remote cursor is removed. 142 | 143 | The underlying event emitter event name is 'remote-cursor-remove'. 144 | 145 | Parameters::: 146 | * cb Callback with the following parameters: 147 | ** remoteCursor - The remote cursor that was removed. 148 | ==== 149 | 150 | ==== 151 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L444[Ritzy.onRemoteCursorChangeName]:: 152 | Register a callback that executes when the name associated with a remote cursor has changed. 153 | 154 | The underlying event emitter event name is 'remote-cursor-change-name'. 155 | 156 | Parameters::: 157 | * cb Callback with the following parameters: 158 | ** remoteCursor - The remote cursor with the changed name. 159 | ** oldName - The old name. 160 | ** newName - The new name. 161 | ==== 162 | 163 | ==== 164 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L460[Ritzy.onTextInsert]:: 165 | Register a callback that executes when text is inserted into the editor. 166 | 167 | The underlying event emitter event name is 'text-insert'. 168 | 169 | Parameters::: 170 | * cb Callback with the following parameters: 171 | ** atPosition - The char position at which the insert occurred. 172 | ** value - The text string that was inserted. 173 | ** attributes - The rich attributes associated with the inserted text. 174 | ** newPosition - The new position of the cursor after the insert is done. An `onPositionChange` event will also be raised separately. 175 | ==== 176 | 177 | ==== 178 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L475[Ritzy.onTextDelete]:: 179 | Register a callback that executes when text is deleted from the editor. 180 | 181 | The underlying event emitter event name is 'text-delete'. 182 | 183 | Parameters::: 184 | * cb Callback with the following parameters: 185 | ** from - The char position from which text was deleted (exclusive). 186 | ** to - The char position to which text was deleted (inclusive). 187 | ** newPosition - The new position of the cursor after the insert is done. An `onPositionChange` event will also be raised separately. 188 | ==== 189 | 190 | [[configuration]] 191 | == Configuration 192 | 193 | The following methods can be used to configure the editor after load time: 194 | 195 | ==== 196 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L196[Ritzy.setUserName]:: 197 | Sets the user name of the editor's user, which will be associated with all remote cursors that 198 | represent the cursor in this editor. Updates remote cursors immediately. 199 | 200 | Parameters::: 201 | * userName The user name to set. 202 | ==== 203 | 204 | ==== 205 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L206[Ritzy.setFontSize]:: 206 | Sets the editor font size, and update the editor contents immediately to reflect this. 207 | 208 | Parameters::: 209 | * fontSize The font size, in pixels, to set. 210 | ==== 211 | 212 | ==== 213 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L216[Ritzy.setWidth]:: 214 | Sets the editor width in pixels, and update the editor immediately to reflect this. 215 | 216 | Parameters::: 217 | * width The width of the editor in pixels. This includes the internal margins. 218 | ==== 219 | 220 | ==== 221 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L229[Ritzy.setMargin]:: 222 | Sets the editor internal margins, and update the editor contents immediately to reflect this. 223 | Margins provide a useful "click area" where the user can click to go to the beginning 224 | or end of a line (or first or last line) without being super-precise about the click. 225 | 226 | Parameters::: 227 | * horizontal The horizontal (left-right) margins. 228 | * vertical The vertical (top-bottom) margins. 229 | ==== 230 | 231 | ==== 232 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L239[Ritzy.setMarginHorizontal]:: 233 | Sets the editor internal horizontal margin. 234 | 235 | Parameters::: 236 | * horizontal The horizontal (left-right) margins. 237 | ==== 238 | 239 | ==== 240 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L250[Ritzy.setMarginVertical]:: 241 | Sets the editor internal vertical margin. 242 | 243 | Parameters::: 244 | * vertical The vertical (top-bottom) margins. 245 | ==== 246 | 247 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.adoc: -------------------------------------------------------------------------------- 1 | = Ritzy Editor Contributor Guide 2 | 3 | [[source]] 4 | == Source 5 | 6 | The source is on GitHub: https://github.com/ritzyed/ritzy. 7 | 8 | [[submit]] 9 | == Submitting Changes 10 | 11 | * Review https://github.com/ritzyed/ritzy/blob/master/docs/DEVELOPMENT.adoc[DEVELOPMENT.adoc] 12 | for notes about how to build and run the editor locally, and related notes 13 | such as code and commit message style. 14 | 15 | * Push your changes to a topic branch in your fork of the repository. 16 | * Submit a pull request to the repository in the ritzyed organization. 17 | * Optionally, add yourself to the AUTHORS file as part of the pull request. 18 | 19 | [[bugs]] 20 | == Bugs 21 | 22 | Bugs/issues/feature requests are managed 23 | https://github.com/ritzyed/ritzy/issues[in GitHub]. 24 | 25 | Editor bugs can be particularly difficult to replicate and track down. Try and 26 | make a repeatable test case / instructions when posting a new issue. 27 | 28 | [[communication]] 29 | == Communication 30 | 31 | * Gitter.im image:https://badges.gitter.im/Join%20Chat.svg[link="https://gitter.im/ritzyed/ritzy?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"] 32 | -------------------------------------------------------------------------------- /docs/DEVELOPMENT.adoc: -------------------------------------------------------------------------------- 1 | = Ritzy Editor Development Guide 2 | 3 | [[source]] 4 | == Policies 5 | 6 | === Commit Message Style 7 | 8 | All git commits should conform to idiomatic git commit message style 9 | http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[described 10 | by Tim Pope] and http://chris.beams.io/posts/git-commit/[expanded on by others]. 11 | 12 | [[devtools]] 13 | == Development Tools 14 | 15 | === Gradle 16 | 17 | Gradle can be used to bootstrap a node and npm environment from scratch. Gradle 18 | itself can bootstrap using the `gradlew` script. If the required versions of 19 | node and npm are already installed at a system level, this step can be skipped. 20 | 21 | Bootstrap gradle: 22 | 23 | ./gradlew wrapper 24 | 25 | Bootstrap node and npm: 26 | 27 | ./gradlew npmSetup 28 | 29 | === NPM 30 | 31 | NPM is used to manage Javascript dependencies. The gradle build will install the 32 | correct version of `npm`, and related dependencies. 33 | 34 | NPM will use whichever version of node is in the PATH, which will likely be the 35 | default system install rather than the project version, if different. 36 | 37 | To solve this, execute all binaries via the `./nodew` script at the root of the 38 | project. 39 | 40 | ./nodew npm ... 41 | ./nodew node ... 42 | ./nodew gulp ... 43 | ./nodew karma ... 44 | 45 | WARNING: Under Windows Cygwin, the platform and architecture detection in 46 | `nodew` is unlikely to work correctly. Set NODE_HOME to override it, and check 47 | the settings are correct with `./nodew npm version`. 48 | 49 | Shell function definitions for node, npm, gulp, etc. similar to the following 50 | may be useful: 51 | 52 | [source,bash] 53 | ---- 54 | npm() { 55 | if [[ -f "./nodew" ]]; then 56 | ./nodew npm $* 57 | elif [[ -f "./npm" ]]; then 58 | ./npm $* 59 | elif [[ -f "./node_modules/.bin/npm" ]]; then 60 | ./node_modules/.bin/npm $* 61 | elif [[ -f /usr/bin/npm ]]; then 62 | /usr/bin/npm $* 63 | else 64 | echo >&2 "npm not found." 65 | fi 66 | } 67 | ---- 68 | 69 | To test for blacklisted modules (listed in package.json): 70 | 71 | npm run-script check-blacklisted 72 | 73 | To test for latest versions of build packages (listed in package.json): 74 | 75 | npm outdated 76 | 77 | Other available scripts include `lint`, `lintdev`, and `test`. Scripts are 78 | listed in `package.json`. 79 | 80 | [[build]] 81 | === Build 82 | 83 | http://gulpjs.com/[Gulp] is the build system used for the frontend. 84 | 85 | To create a minified and compiled version of all assets (including CSS 86 | preprocessors such as less and sass, and compilation of typescript files), run: 87 | 88 | gulp build --release 89 | 90 | Or to produce non-minified CSS and JS files in the build: 91 | 92 | gulp build 93 | 94 | To deploy documentation to GitHub pages (use `--production` or `--staging` as 95 | necessary, see the build file): 96 | 97 | gulp deploy # or, `gulp deploy --production` 98 | 99 | To watch source files for changes and automatically update the compiled build 100 | (to see changes immediately in Play, for example), leave the gulp serve task 101 | running: 102 | 103 | gulp serve 104 | 105 | Specify the task before other arguments: e.g. 106 | 107 | gulp build --verbose 108 | 109 | [[debug]] 110 | === Debug 111 | 112 | The client is best debugged via browser developer tools. 113 | 114 | The server can be debugged and profiled using 115 | https://github.com/node-inspector/node-inspector[node-inspector]. 116 | 117 | npm install -g node-inspector 118 | ./nodew node-inspector 119 | 120 | Then start the server in debug mode (or for a running server send the server 121 | process a 122 | https://github.com/node-inspector/node-inspector#2-enable-debug-mode-in-your-node-process[USR1 123 | signal]): 124 | 125 | gulp serve --debug 126 | 127 | or 128 | 129 | gulp serve --debugbrk 130 | 131 | [[redis]] 132 | === Redis 133 | 134 | The default server-side database is Redis. See 135 | https://github.com/ritzyed/ritzy/blob/master/src/core/swarmserver.js[swarmserver.js] 136 | for the Redis connection details. 137 | 138 | ==== Useful Redis CLI Commands 139 | 140 | * Delete several keys matching a wildcard: 141 | 142 | EVAL "return redis.call('del', unpack(redis.call('keys', ARGV[1])))" 0 "/Cursor#*" 143 | 144 | * Copy the state of editor 10 to 11 for debugging: 145 | 146 | SCRIPT LOAD "return redis.call('restore', ARGV[2], 0, redis.call('dump', ARGV[1]))" 147 | EVALSHA "bfb3fde399b1b363c6d5617b8d955bb4f7aea907" 0 "/Text#10" "/Text#11" 148 | EVALSHA "bfb3fde399b1b363c6d5617b8d955bb4f7aea907" 0 "/Text#10:log" "/Text#11:log" 149 | 150 | [[testing]] 151 | === Testing 152 | 153 | ==== Javascript Off-Browser 154 | 155 | Javascript unit tests are written using http://mochajs.org/[Mocha] and 156 | assertions using http://chaijs.com/[Chai]. Tests are named 157 | `-test.js`. 158 | 159 | To execute: 160 | 161 | npm test 162 | 163 | NOTE: The default Facebook library for testing React applications is Jest, but 164 | Jest is slow and classes under test had strange issues like array pushes 165 | failing. Mocha seems to be more consistent. IntelliJ IDEA can also run and debug 166 | Mocha tests. 167 | 168 | NOTE: `jsdom` is limited to version 3.x. 4.x and above only works with `io.js` 169 | and not with NodeJS. 170 | 171 | More information: 172 | 173 | * http://www.hammerlab.org/2015/02/14/testing-react-web-apps-with-mocha/ 174 | 175 | ==== Javascript In-Browser 176 | 177 | In cases where a browser API is required for the test, the unit tests are named 178 | `-testb.js`. Tests are executed via the 179 | http://karma-runner.github.io/[Karma] runner. 180 | 181 | To execute: 182 | 183 | npm run-script testb 184 | 185 | (testb stands for "test in browser") 186 | 187 | ==== Browser Sync 188 | 189 | Running the application via `gulp sync` will run a 190 | http://www.browsersync.io/[BrowserSync] session. This provides live reload 191 | functionality in the browser when changes are made to server-side code. It will 192 | also synchronize multiple browsers (clicks, scrolling, and so forth), which is 193 | useful for multi-browser verification. 194 | 195 | WARNING: Current BrowserSync does not support websocket connections. Therefore 196 | `gulp sync` is not yet useful. 197 | 198 | [[intellij-idea]] 199 | === Intellij IDEA 200 | 201 | IntelliJ can debug Javascript with the appropriate plugins installed in IDEA. 202 | Note that if you use Chrome for normal browsing, you should use a different 203 | Chrome profile for IDEA -- set this in Settings, Web Browsers, Chrome, Edit 204 | 205 | ==== Debugging ==== 206 | 207 | Debug client-side Javascript in IDEA using the run configuration `Debug Frontend 208 | (npm start)`. Debug server-side Javascript (NodeJS) by using the run 209 | configuration `NodeJS Remote Debug`, and start the server with a `--debug` flag 210 | e.g. `./gulp serve --debug`. 211 | 212 | WARNING: There appears to be a bug in IntelliJ that causes it to not use the 213 | source map between the Javascript file in the `src` directory vs the one 214 | actually being executed (after processing by webpack) in the build 215 | directory (possibly https://youtrack.jetbrains.com/issue/WEB-14000[this one]). 216 | To work around this, set the breakpoints in the `/.../whatever.js` 217 | file instead of the original file. Once they are set, the breakpoints will still 218 | trigger in the original src file. 219 | 220 | [[codestyle]] 221 | == Coding Style 222 | 223 | === Eslint === 224 | 225 | http://eslint.org/[ESLint] is used for checking JavaScript styles and for common 226 | errors. The project's rules are defined in ``.eslintrc`. 227 | 228 | === Editor Config === 229 | 230 | http://editorconfig.org/[EditorConfig] is used to maintain consistent coding 231 | styles between various editors and IDEs. The project's rules are defined in 232 | `.editorconfig`. 233 | 234 | === JavaScript Modules 235 | 236 | Use ES6 module export and import syntax. Webpack with an ES6 transpiler is fully 237 | capable of handling this. 238 | 239 | === JavaScript Style Guide 240 | 241 | Use the https://docs.npmjs.com/misc/coding-style[npm coding style]. Note, as per 242 | npm, we don't use semi-colon termination. We do use semi-colon prefixes when 243 | http://inimino.org/~inimino/blog/javascript_semicolons[required]. Exceptions: 244 | 245 | * Line lengths <~ 120 (not a strict limit, but a useful guideline) 246 | 247 | * "," at the end of comma-separated values as is normal (the benefit of putting 248 | them at the beginning is clear, but it just plain makes code look weird) 249 | 250 | === React/JSX Style Guide 251 | 252 | React components should be declared in `.js` files and use JSX syntax. Use the 253 | following conventions: 254 | 255 | . Layout the React component methods in rough 256 | https://facebook.github.io/react/docs/component-specs.html#lifecycle-methods[lifecycle 257 | order] (`displayName` is not necessary when using JSX): 258 | + 259 | [source,javascript] 260 | ---- 261 | React.createClass({ 262 | propTypes: {}, 263 | mixins : [], 264 | 265 | getDefaultProps() {}, 266 | getInitialState() {}, 267 | 268 | componentWillMount() {}, 269 | componentDidMount() {}, 270 | componentWillReceiveProps(nextProps) {}, 271 | shouldComponentUpdate(nextProps, nextState) {}, 272 | componentWillUpdate(nextProps, nextState) {}, 273 | componentDidUpdate(prevProps, prevState) {}, 274 | componentWillUnmount() {}, 275 | 276 | // other public methods 277 | 278 | _parseData() {}, 279 | _onSelect() {}, 280 | 281 | render() {} 282 | }); 283 | ---- 284 | NOTE: The above uses ES6 285 | http://people.mozilla.org/~jorendorff/es6-draft.html#sec-object-initializer[object 286 | initializer method definitions] as a function declaration 287 | https://github.com/lukehoban/es6features#enhanced-object-literals[shorthand]. 288 | + 289 | Custom functions should be prefixed with `_` and placed above the render method. 290 | 291 | . Variables containing conditional HTML should be suffixed with `Html` e.g.: 292 | + 293 | [source,javascript] 294 | ---- 295 | var dinosaurHtml = ''; 296 | if (this.state.showDinosaurs) { 297 | dinosaurHtml = ( 298 |
299 | 300 | 301 |
302 | ); 303 | } 304 | 305 | return ( 306 |
307 | ... 308 | {dinosaurHtml} 309 | ... 310 |
311 | ); 312 | ---- 313 | 314 | . JSX spanning multiple lines should be wrapped in parentheses as above. 315 | 316 | . List iterations can be done inline using an ES6 `map` function. 317 | -------------------------------------------------------------------------------- /docs/INSTALLATION.adoc: -------------------------------------------------------------------------------- 1 | = Ritzy Installation 2 | :toc: 3 | :sectanchors: 4 | 5 | == General 6 | 7 | Ritzy is an ES6 React component with an optional API wrapper. Currently, it does 8 | require a server-side implementation to support collaborative editing. In the 9 | future, this will be optional. 10 | 11 | [[es6]] 12 | === ES6 13 | 14 | The editor uses JavaScript ES6. Ensure your consuming client and server-side 15 | code transpiles to ES5 via babel or similar transpiler, and contains the 16 | appropriate ES6 polyfills. See the 17 | https://github.com/ritzyed/ritzy-demo[Ritzy demo] for an example. 18 | 19 | Alternatively, use the pre-transpiled and (optionally) minified library 20 | available in the package `dist` directory. 21 | 22 | [[cs]] 23 | === Client-Side 24 | 25 | [[cs_react]] 26 | ==== React Projects 27 | 28 | The React component is 29 | https://github.com/ritzyed/ritzy/blob/master/src/components/Editor.js[Editor]. 30 | There are several props necessary to initialize the editor properly. See 31 | <> below for information about the props that can be passed. 32 | 33 | TIP: The `ritzy` module provides a useful API for the editor. Consider using 34 | that instead. 35 | 36 | [[cs_other]] 37 | ==== Other Projects 38 | 39 | See https://github.com/ritzyed/ritzy/blob/master/src/client.js[client.js] or the 40 | https://github.com/ritzyed/ritzy-demo[Ritzy demo] for an example of creating and 41 | using the editor using its `ritzy` module . Note that `client.js` uses the 42 | `ritzy` module, which wraps the underlying React `Editor` component. It provides 43 | a useful https://github.com/ritzyed/ritzy/blob/master/docs/API.adoc[API] to 44 | calling code, including methods to control the editor at runtime and event 45 | registration. 46 | 47 | TIP: If you are building a React app, you are free to embed the `Editor` 48 | component directly rather than using the `ritzy` module. See above. 49 | 50 | It should be possible to create multiple editors on one page, though this is not 51 | yet a tested configuration. 52 | 53 | [[configuration]] 54 | ==== Configuration 55 | 56 | TODO 57 | 58 | For now, see 59 | https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js[ritzy.js], 60 | https://github.com/ritzyed/ritzy/blob/master/src/client.js[client.js] and the 61 | https://github.com/ritzyed/ritzy-demo[Ritzy demo] for examples. 62 | 63 | [[ss]] 64 | === Server-Side === 65 | 66 | The server-side integration mechanism for most applications employing Ritzy will 67 | be to create a Ritzy Swarm.js peer within their server-side application, which 68 | will be responsible for receiving all updates to text replicas. The application 69 | can then use that text replica for any purpose. 70 | 71 | See https://github.com/ritzyed/ritzy/blob/master/src/server.js[server.js] or the 72 | https://github.com/ritzyed/ritzy-demo[Ritzy demo] for an example of 73 | this. 74 | 75 | Currently, Swarm.js peers only run within JavaScript environments, but the 76 | author http://swarmjs.github.io/articles/android-is-coming/[plans] to support 77 | other languages in the future. 78 | 79 | For commercial support with additional server-side features, see the 80 | https://github.com/ritzyed/ritzy/blob/master/README.adoc#commercial_features[README]. 81 | -------------------------------------------------------------------------------- /docs/images/char_ids.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritzyed/ritzy/39e28033cbd4808d90a821a95e9c32aba3195a2c/docs/images/char_ids.png -------------------------------------------------------------------------------- /extractapi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A little module that extracts the API information from doc comments. 3 | * @type {exports|module.exports} 4 | */ 5 | 6 | var fs = require('fs') 7 | var path = require('path') 8 | var espree = require('espree') 9 | 10 | var code = fs.readFileSync('./src/ritzy.js', {encode: 'utf8'}).toString() 11 | 12 | var ast = espree.parse(code, { 13 | attachComment: true, 14 | loc: true, 15 | ecmaFeatures: { 16 | arrowFunctions: true, 17 | blockBindings: true, 18 | destructuring: true, 19 | regexYFlag: true, 20 | regexUFlag: true, 21 | templateStrings: true, 22 | binaryLiterals: true, 23 | octalLiterals: true, 24 | unicodeCodePointEscapes: true, 25 | defaultParams: true, 26 | restParams: true, 27 | forOf: true, 28 | objectLiteralComputedProperties: true, 29 | objectLiteralShorthandMethods: true, 30 | objectLiteralShorthandProperties: true, 31 | objectLiteralDuplicateProperties: true, 32 | generators: true, 33 | spread: true, 34 | classes: true, 35 | modules: true, 36 | jsx: true, 37 | globalReturn: true 38 | } 39 | }) 40 | 41 | var parseComment = function(comment) { 42 | // from esdoc (https://github.com/esdoc/esdoc/blob/master/src/Parser/CommentParser.js) 43 | comment = comment.replace(/\r\n/gm, '\n') // for windows 44 | comment = comment.replace(/^\t*\s?/gm, '') // remove trailing tab 45 | comment = comment.replace(/^\*\s?/, '') // remove first '*' 46 | comment = comment.replace(/ $/, '') // remove last ' ' 47 | comment = comment.replace(/^ *\* ?/gm, '') // remove line head '*' 48 | if (comment.charAt(0) !== '@') comment = '@desc ' + comment // auto insert @desc 49 | comment = comment.replace(/\s*$/, '') // remove tail space. 50 | comment = comment.replace(/^(@\w+)$/gm, '$1 \\TRUE') // auto insert tag text to non-text tag (e.g. @interface) 51 | comment = comment.replace(/^(@\w+)\s(.*)/gm, '\\Z$1\\Z$2') // insert separator (\\Z@tag\\Ztext) 52 | var lines = comment.split('\\Z') 53 | 54 | var tagName = '' 55 | var tagValue = '' 56 | var tags = [] 57 | for (var i = 0; i < lines.length; i++) { 58 | var line = lines[i] 59 | if (line.charAt(0) === '@') { 60 | tagName = line 61 | var nextLine = lines[i + 1] 62 | if (nextLine.charAt(0) === '@') { 63 | tagValue = '' 64 | } else { 65 | tagValue = nextLine 66 | i++ 67 | } 68 | tagValue = tagValue.replace('\\TRUE', '').replace(/^\n/, '').replace(/\n*$/, '') 69 | var tag = {} 70 | tag[tagName] = tagValue 71 | tags.push(tag) 72 | } 73 | } 74 | return tags 75 | } 76 | 77 | var apiInfo = ast.body.filter(function(node) { 78 | return node.type === 'ExportDefaultDeclaration' && node.declaration.id.name === 'RitzyFactory' 79 | })[0].declaration.body.body.filter(function(node) { 80 | return node.type === 'ClassDeclaration' && node.id.name === 'Ritzy' 81 | })[0].body.body.filter(function(node) { 82 | return node.type === 'MethodDefinition' && node.leadingComments 83 | }).map(function(node) { 84 | return { 85 | name: node.key.name, 86 | line: node.key.loc.start.line, 87 | comments: parseComment(node.leadingComments[0].value) 88 | } 89 | }) 90 | 91 | var printApiMethod = function(apiMethod) { 92 | //console.log(metadata) 93 | //console.log(metadata.comments) 94 | var docString = '====\n' + 95 | 'https://github.com/ritzyed/ritzy/blob/master/src/ritzy.js#L' + apiMethod.line + '[Ritzy.' + apiMethod.name + ']::\n' + 96 | apiMethod.comments.filter(function(c) { return c.hasOwnProperty('@desc') })[0]['@desc'] + '\n' 97 | 98 | var params = apiMethod.comments.filter(function(c) { return c.hasOwnProperty('@param') }) 99 | if(params.length > 0) { 100 | docString += '\nParameters:::\n' 101 | params.map(function(p) { return p['@param'] }).forEach(function(p) { 102 | docString += '* ' + p + '\n' 103 | }) 104 | } 105 | 106 | docString += '====\n' 107 | return docString 108 | } 109 | 110 | var byApiMethod = function(apiDocString) { 111 | return function(apiMethod) { 112 | return apiMethod.comments.filter(function(c) { 113 | return c.hasOwnProperty('@apidoc') && c['@apidoc'] === apiDocString 114 | }).length > 0 115 | } 116 | } 117 | 118 | var writeSection = function(section, sectionRef, sectionDescription) { 119 | process.stdout.write('[[' + sectionRef + ']]\n== ' + section + '\n\n' + sectionDescription + '\n\n') 120 | apiInfo.filter(byApiMethod(section)).map(printApiMethod).forEach(function(apiDoc) { 121 | process.stdout.write(apiDoc + '\n') 122 | }) 123 | } 124 | 125 | process.stdout.write('= Ritzy Editor Client API\n:sectanchors:\n\n') 126 | writeSection('Contents', 'contents', 'The content of the editor can be retrieved by one of the following methods:') 127 | writeSection('Selection', 'selection', 'The current selection can be retrieved by one of the following methods:') 128 | writeSection('Cursors', 'cursor', 'Information about local and remote cursors is available:') 129 | writeSection('Events', 'events', 'The following methods can be used to register listeners for events:') 130 | writeSection('Configuration', 'configuration', 'The following methods can be used to configure the editor after load time:') 131 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # This file can be overwridden by adding the property into $HOME/.gradle/gradle.properties 2 | node.download=true 3 | -------------------------------------------------------------------------------- /gradle/gradle.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritzyed/ritzy/39e28033cbd4808d90a821a95e9c32aba3195a2c/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jun 15 15:40:37 EDT 2015 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-bin.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # For Cygwin, ensure paths are in UNIX format before anything is touched. 46 | if $cygwin ; then 47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 48 | fi 49 | 50 | # Attempt to set APP_HOME 51 | # Resolve links: $0 may be a link 52 | PRG="$0" 53 | # Need this for relative symlinks. 54 | while [ -h "$PRG" ] ; do 55 | ls=`ls -ld "$PRG"` 56 | link=`expr "$ls" : '.*-> \(.*\)$'` 57 | if expr "$link" : '/.*' > /dev/null; then 58 | PRG="$link" 59 | else 60 | PRG=`dirname "$PRG"`"/$link" 61 | fi 62 | done 63 | SAVED="`pwd`" 64 | cd "`dirname \"$PRG\"`/" >&- 65 | APP_HOME="`pwd -P`" 66 | cd "$SAVED" >&- 67 | 68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 69 | 70 | # Determine the Java command to use to start the JVM. 71 | if [ -n "$JAVA_HOME" ] ; then 72 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 73 | # IBM's JDK on AIX uses strange locations for the executables 74 | JAVACMD="$JAVA_HOME/jre/sh/java" 75 | else 76 | JAVACMD="$JAVA_HOME/bin/java" 77 | fi 78 | if [ ! -x "$JAVACMD" ] ; then 79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 80 | 81 | Please set the JAVA_HOME variable in your environment to match the 82 | location of your Java installation." 83 | fi 84 | else 85 | JAVACMD="java" 86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 87 | 88 | Please set the JAVA_HOME variable in your environment to match the 89 | location of your Java installation." 90 | fi 91 | 92 | # Increase the maximum file descriptors if we can. 93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 94 | MAX_FD_LIMIT=`ulimit -H -n` 95 | if [ $? -eq 0 ] ; then 96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 97 | MAX_FD="$MAX_FD_LIMIT" 98 | fi 99 | ulimit -n $MAX_FD 100 | if [ $? -ne 0 ] ; then 101 | warn "Could not set maximum file descriptor limit: $MAX_FD" 102 | fi 103 | else 104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 105 | fi 106 | fi 107 | 108 | # For Darwin, add options to specify how the application appears in the dock 109 | if $darwin; then 110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 111 | fi 112 | 113 | # For Cygwin, switch paths to Windows format before running java 114 | if $cygwin ; then 115 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 116 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /* global argv */ 2 | 3 | // Include Gulp and other build automation tools and utilities 4 | // See: https://github.com/gulpjs/gulp/blob/master/docs/API.md 5 | var gulp = require('gulp') 6 | var $ = require('gulp-load-plugins')() 7 | var del = require('del') 8 | var path = require('path') 9 | var runSequence = require('run-sequence') 10 | var webpack = require('webpack') 11 | var options = require('minimist')(process.argv.slice(2), { 12 | alias: { 13 | debug: 'd', 14 | debugbrk: 'D', 15 | verbose: 'V', 16 | profile: 'p' 17 | }, 18 | boolean: ['debug', 'debugbrk', 'verbose', 'profile'], 19 | default: { 20 | debug: false, 21 | debugbrk: false, 22 | verbose: false, 23 | profile: false 24 | } 25 | }) 26 | 27 | $.util.log('[args]', ' debug = ' + options.debug) 28 | $.util.log('[args]', 'debugbrk = ' + options.debugbrk) 29 | $.util.log('[args]', ' verbose = ' + options.verbose) 30 | $.util.log('[args]', ' profile = ' + options.profile) 31 | 32 | // https://github.com/ai/autoprefixer 33 | options.autoprefixer = [ 34 | 'last 2 version' 35 | ] 36 | 37 | var paths = { 38 | build: 'build', 39 | dist: 'dist', 40 | lib: 'lib', 41 | src: [ 42 | 'src/**/*.js', 43 | '!src/server.js', 44 | '!src/**/__tests__/**/*.js', 45 | '!src/**/__mocks__/**/*.js', 46 | '!src/assets/*', 47 | '!src/templates/*', 48 | '!src/tests/*' 49 | ] 50 | } 51 | var src = { 52 | assets: [ 53 | 'src/assets/**', 54 | 'src/templates*/**' 55 | ], 56 | server: [ 57 | paths.build + '/client.js', 58 | paths.build + '/server.js', 59 | paths.build + '/templates/**/*' 60 | ] 61 | } 62 | var watch = false 63 | var browserSync 64 | 65 | var DEVELOPMENT_HEADER = [ 66 | '/**', 67 | ' * Ritzy v<%= version %>', 68 | ' */' 69 | ].join('\n') + '\n' 70 | 71 | var PRODUCTION_HEADER = [ 72 | '/**', 73 | ' * Ritzy v<%= version %>', 74 | ' *', 75 | ' * Copyright 2015, VIVO Systems, Inc.', 76 | ' * All rights reserved.', 77 | ' *', 78 | ' * This source code is licensed under the Apache v2 license found in the', 79 | ' * LICENSE.txt file in the root directory of this source tree.', 80 | ' *', 81 | ' */' 82 | ].join('\n') + '\n' 83 | 84 | var webpackOpts = function(output, configs, debug) { 85 | return require('./webpack.config.js')(output, configs, debug, options.verbose, options.autoprefixer) 86 | } 87 | var webpackCompletion = function(err, stats) { 88 | if(err) { 89 | throw new $.util.PluginError('webpack', err, {showStack: true}) 90 | } 91 | var jsonStats = stats.toJson() 92 | var statsOptions = { colors: true/*, modulesSort: 'size'*/ } 93 | if(jsonStats.errors.length > 0) { 94 | if(watch) { 95 | $.util.log('[webpack]', stats.toString(statsOptions)) 96 | } else { 97 | throw new $.util.PluginError('webpack', stats.toString(statsOptions)) 98 | } 99 | } 100 | if(jsonStats.warnings.length > 0 || options.verbose) { 101 | $.util.log('[webpack]', stats.toString(statsOptions)) 102 | } 103 | if(jsonStats.errors.length === 0 && jsonStats.warnings.length === 0) { 104 | $.util.log('[webpack]', 'No errors or warnings.') 105 | } 106 | } 107 | 108 | // Check the version of node currently being used 109 | gulp.task('node-version', function(cb) { // eslint-disable-line no-unused-vars 110 | return require('child_process').fork(null, {execArgv: ['--version']}) 111 | }) 112 | 113 | // The default task 114 | gulp.task('default', ['serve']) 115 | 116 | // Clean output directory 117 | gulp.task('clean', ['clean:lib', 'clean:build', 'clean:dist'], del.bind( 118 | null, ['.tmp'], {dot: true} 119 | )) 120 | 121 | gulp.task('clean:lib', del.bind( 122 | null, [paths.lib + '/*'], {dot: true} 123 | )) 124 | 125 | gulp.task('clean:build', del.bind( 126 | null, [paths.build], {dot: true} 127 | )) 128 | 129 | gulp.task('clean:dist', del.bind( 130 | null, [paths.dist + '/*', '!' + paths.dist + '/.git'], {dot: true} 131 | )) 132 | 133 | // Static files 134 | gulp.task('assets', function() { 135 | return gulp.src(src.assets) 136 | .pipe($.changed(paths.build)) 137 | .pipe(gulp.dest(paths.build)) 138 | .pipe($.size({title: 'assets'})) 139 | }) 140 | 141 | var compile = function(cb, webpackConfigs) { 142 | var started = false 143 | function webpackCb(err, stats) { 144 | webpackCompletion(err, stats) 145 | if (!started) { 146 | started = true 147 | return cb() 148 | } 149 | } 150 | 151 | var compiler = webpack(webpackOpts(paths.build, webpackConfigs, true)) 152 | if (watch) { 153 | compiler.watch(200, webpackCb) 154 | } else { 155 | compiler.run(webpackCb) 156 | } 157 | } 158 | 159 | gulp.task('compile:client', function(cb) { 160 | compile(cb, {client: true}) 161 | }) 162 | 163 | gulp.task('compile:server', function(cb) { 164 | compile(cb, {server: true}) 165 | }) 166 | 167 | // Build the app from source code 168 | gulp.task('build', ['clean:build'], function(cb) { 169 | runSequence(['assets', 'compile:client', 'compile:server'], cb) 170 | }) 171 | 172 | // Build and start watching for modifications 173 | gulp.task('build:watch', function(cb) { 174 | watch = true 175 | runSequence('build', function(err) { 176 | gulp.watch(src.assets, ['assets']) 177 | cb(err) 178 | }) 179 | }) 180 | 181 | // Launch a Node.js/Express server 182 | gulp.task('serve', ['build:watch'], function(cb) { 183 | var started = false 184 | var cp = require('child_process') 185 | var assign = require('react/lib/Object.assign') 186 | var nodeArgs = {} 187 | if(options.debug) { 188 | $.util.log('[node]', 'Node.js debug port set to 5858.') 189 | if(!nodeArgs.execArgv) nodeArgs.execArgv = [] 190 | nodeArgs.execArgv.push('--debug=5858') 191 | } else if(options.debugbrk) { 192 | $.util.log('[node]', 'Node.js debug break port set to 5858.') 193 | if (!nodeArgs.execArgv) nodeArgs.execArgv = [] 194 | nodeArgs.execArgv.push('--debug-brk=5858') 195 | } 196 | if(options.profile) { 197 | $.util.log('[node]', 'Node.js v8 profiling activated. Check for v8.log.') 198 | if(!nodeArgs.execArgv) nodeArgs.execArgv = [] 199 | nodeArgs.execArgv.push('--prof') 200 | nodeArgs.execArgv.push('--log-timer-events') 201 | } 202 | 203 | var server = (function startup() { 204 | var child = cp.fork(paths.build + '/server.js', nodeArgs, { 205 | env: assign({NODE_ENV: 'development'}, process.env) 206 | }) 207 | child.once('message', function(message) { 208 | if (message.match(/^online$/)) { 209 | if (browserSync) { 210 | browserSync.reload() 211 | } 212 | if (!started) { 213 | started = true 214 | gulp.watch(src.server, function() { 215 | $.util.log('Restarting development server.') 216 | server.kill('SIGTERM') 217 | server = startup() 218 | }) 219 | cb() 220 | } 221 | } 222 | }) 223 | return child 224 | })() 225 | }) 226 | 227 | // Launch BrowserSync development server 228 | gulp.task('sync', ['serve'], function(cb) { 229 | browserSync = require('browser-sync') 230 | 231 | browserSync({ 232 | notify: false, 233 | // Run as an https by setting 'https: true' 234 | // Note: this uses an unsigned certificate which on first access 235 | // will present a certificate warning in the browser. 236 | https: false, 237 | // Informs browser-sync to proxy our Express app which would run 238 | // at the following location 239 | proxy: 'localhost:5000' 240 | }, cb) 241 | 242 | process.on('exit', function() { 243 | browserSync.exit() 244 | }) 245 | 246 | gulp.watch([paths.build + '/**/*.*'].concat( 247 | src.server.map(function(file) { return '!' + file }) 248 | ), function(file) { 249 | browserSync.reload(path.relative(__dirname, file.path)) 250 | }) 251 | }) 252 | 253 | gulp.task('modules', ['clean:lib'], function() { 254 | return gulp 255 | .src(paths.src, {base: 'src'}) 256 | .pipe($.babel()) 257 | .pipe(gulp.dest(paths.lib)) 258 | }) 259 | 260 | var dist = function(cb, header, debug, lib) { 261 | function webpackCb(err, stats) { 262 | webpackCompletion(err, stats) 263 | gulp.src(paths.build + '/' + lib) 264 | .pipe($.header(header, { 265 | version: process.env.npm_package_version 266 | })) 267 | .pipe(gulp.dest(paths.dist)) 268 | return cb() 269 | } 270 | 271 | webpack(webpackOpts(paths.build, { lib: true, libName: lib }, debug), webpackCb) 272 | } 273 | 274 | gulp.task('dist:dev', ['modules'], function(cb) { 275 | return dist(cb, DEVELOPMENT_HEADER, true, 'ritzy.js') 276 | }) 277 | 278 | gulp.task('dist:min', ['modules'], function(cb) { 279 | return dist(cb, PRODUCTION_HEADER, false, 'ritzy.min.js') 280 | }) 281 | 282 | gulp.task('dist', ['clean:dist', 'dist:dev', 'dist:min'], function(cb) { 283 | return gulp.src('src/assets/fonts/**/*', {base: 'src/assets'}) 284 | .pipe(gulp.dest(paths.dist)) 285 | }) 286 | 287 | gulp.task('docs', function() { 288 | var tests = ['-testb?\\.js$'] 289 | gulp.src('src') 290 | .pipe($.esdoc({ 291 | title: 'Ritzy: Collaborative web-based rich text editor', 292 | destination: paths.dist + '/docs', 293 | excludes: tests, 294 | access: ['public', 'protected', 'private'], 295 | test: { 296 | type: 'mocha', 297 | source: 'src/', 298 | includes: tests 299 | }/*, 300 | unexportIdentifier: true*/ 301 | })) 302 | }) 303 | 304 | // Deploy to GitHub Pages 305 | gulp.task('deploy', function() { 306 | // Remove temp folder 307 | if (argv.clean) { 308 | var os = require('os') 309 | var repoPath = path.join(os.tmpdir(), 'tmpRepo') 310 | $.util.log('Delete ' + $.util.colors.magenta(repoPath)) 311 | del.sync(repoPath, {force: true}) 312 | } 313 | 314 | return gulp.src(paths.build + '/**/*') 315 | .pipe($.if('**/robots.txt', !argv.production ? 316 | $.replace('Disallow:', 'Disallow: /') : $.util.noop())) 317 | .pipe($.ghPages({ 318 | remoteUrl: 'https://github.com/ritzyed/ritzy.github.io.git', 319 | branch: 'master' 320 | })) 321 | }) 322 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | basePath: '', 4 | frameworks: ['mocha', 'chai'], 5 | files: [ 6 | 'src/tests/karma-test-entry.js', 7 | { pattern: 'src/**/*.js', included: false, served: false }, 8 | 'src/**/*.html' 9 | ], 10 | excluded: [ 11 | '**/*-test.js' 12 | ], 13 | preprocessors: { 14 | 'src/tests/karma-test-entry.js': ['webpack', 'sourcemap'] 15 | }, 16 | webpack: { 17 | devtool: 'inline-source-map', 18 | module: { 19 | loaders: [ 20 | { 21 | test: /\.js$/, 22 | exclude: /node_modules/, 23 | loader: 'babel-loader' 24 | } 25 | ] 26 | } 27 | }, 28 | webpackServer: { 29 | //quiet: true, 30 | stats: { 31 | colors: true 32 | } 33 | }, 34 | client: { 35 | mocha: { 36 | reporter: 'html' // change Karma's debug.html to the mocha web reporter 37 | } 38 | }, 39 | logLevel: config.LOG_INFO, 40 | // For IE testing in VMs see https://github.com/xdissent/karma-ievms or just connect to host port 9876 from the VM 41 | // iectrl open -s 10,11 http://:9876/ 42 | browsers : ['Chrome', 'Firefox'/*, 'IE10 - Win7', 'IE11 - Win7'*/], 43 | singleRun: true 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /nodew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # https://github.com/srs/gradle-node-plugin/issues/24 3 | 4 | DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 5 | # This should match the "workDir" setting in build.gradle 6 | NODEJS=$DIR/gradle/nodejs 7 | NODE=node 8 | NODE_VERSION_DEFAULT=0.12.7 9 | #NODE=iojs 10 | #NODE_VERSION_DEFAULT=1.7.1 11 | 12 | # This probably won't work on Windows/Cygwin. Set NODE_HOME explicitly. 13 | platform=$(uname -s | tr '[:upper:]' '[:lower:]') 14 | typeset -A arches=([x86_64]=x64 [x86]=x86) # not sure about the x86 15 | arch=$(uname -m | tr '[:upper:]' '[:lower:]') 16 | nodearch=${arches[$arch]} 17 | 18 | # All of these may be overridden by the calling environment if needed. 19 | NODE_VERSION=${NODE_VERSION:-${NODE_VERSION_DEFAULT}} 20 | NODE_HOME="${NODE_HOME:-$NODEJS/${NODE}-v${NODE_VERSION}-${platform}-${nodearch}}" 21 | NPM_HOME="${NPM_HOME:-${NODE_HOME}/lib/node_modules/npm}" 22 | NODE_MODULES_HOME="${NODE_MODULES_HOME:-$DIR/node_modules}" 23 | 24 | PATH="${NODE_MODULES_HOME}/.bin:${NPM_HOME}/bin:${NODE_HOME}/bin:${PATH}" 25 | 26 | exec "${@}" 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ritzy", 3 | "version": "0.1.4", 4 | "description": "Ritzy Collaborative Web-based Rich Text Editor", 5 | "keywords": [ 6 | "editor", 7 | "text editor", 8 | "rich text", 9 | "richtext", 10 | "react", 11 | "wysiwyg", 12 | "wysiwym" 13 | ], 14 | "homepage": "http://ritzyed.github.io/ritzy", 15 | "bugs": "https://github.com/ritzyed/ritzy/issues", 16 | "license": "Apache-2.0", 17 | "author": "Raman Gupta (https://github.com/rocketraman/)", 18 | "files": [ 19 | "dist/", 20 | "docs/", 21 | "lib/" 22 | ], 23 | "main": "dist/ritzy.js", 24 | "repository": "ritzyed/ritzy", 25 | "engines": { 26 | "node": ">= 0.12", 27 | "npm": ">= 2.11" 28 | }, 29 | "dependencies": { 30 | "alt": "0.17.1", 31 | "classnames": "2.1.3", 32 | "eventemitter3": "1.1.1", 33 | "express": "4.13.1", 34 | "lodash": "3.10.0", 35 | "mousetrap": "1.5.3", 36 | "opentype.js": "0.4.9", 37 | "react": "0.13.3", 38 | "react-spinkit": "1.1.2", 39 | "redis": "^0.12.1", 40 | "swarm": "0.3.25", 41 | "swarm-restapi": "0.3.24", 42 | "webfontloader": "1.6.4", 43 | "ws": "0.7.2" 44 | }, 45 | "devDependencies": { 46 | "autoprefixer-loader": "^2.0.0", 47 | "babel": "5.8.21", 48 | "babel-core": "^5.8.22", 49 | "babel-eslint": "^4.0.5", 50 | "babel-loader": "^5.3.2", 51 | "blacklisted-gulp": "^0.0.3", 52 | "browser-sync": "^2.8.2", 53 | "chai": "^3.2.0", 54 | "css-loader": "^0.15.6", 55 | "del": "^1.2.0", 56 | "eslint": "^1.1.0", 57 | "eslint-loader": "^1.0.0", 58 | "eslint-plugin-react": "^3.2.2", 59 | "espree": "^2.2.4", 60 | "file-loader": "^0.8.4", 61 | "gulp": "^3.9.0", 62 | "gulp-autoprefixer": "^2.3.1", 63 | "gulp-babel": "^5.2.0", 64 | "gulp-cache": "^0.2.10", 65 | "gulp-changed": "^1.3.0", 66 | "gulp-csscomb": "^3.0.6", 67 | "gulp-esdoc": "0.0.3", 68 | "gulp-gh-pages": "^0.5.2", 69 | "gulp-header": "^1.2.2", 70 | "gulp-if": "^1.2.5", 71 | "gulp-load-plugins": "^1.0.0-rc", 72 | "gulp-replace": "^0.5.4", 73 | "gulp-size": "^1.3.0", 74 | "gulp-util": "^3.0.6", 75 | "jsdom": "^3.1.2", 76 | "karma": "^0.13.9", 77 | "karma-chai": "^0.1.0", 78 | "karma-chrome-launcher": "^0.2.0", 79 | "karma-firefox-launcher": "^0.1.6", 80 | "karma-mocha": "^0.2.0", 81 | "karma-sourcemap-loader": "^0.3.5", 82 | "karma-webpack": "^1.6.0", 83 | "less": "^2.5.1", 84 | "less-loader": "^2.2.0", 85 | "minimist": "^1.1.3", 86 | "mocha": "^2.2.5", 87 | "npm": "^2.11.3", 88 | "psi": "^1.0.6", 89 | "react-tools": "^0.13.3", 90 | "run-sequence": "^1.1.2", 91 | "style-loader": "^0.12.3", 92 | "url-loader": "^0.5.6", 93 | "webpack": "^1.11.0" 94 | }, 95 | "scripts": { 96 | "start": "gulp", 97 | "test": "mocha --compilers js:compiler.js src/**/*-test.js", 98 | "lint": "eslint src/", 99 | "lintdev": "eslint -c .eslintdevrc gulpfile.js webpack.config.js extractapi.js", 100 | "testb": "karma start", 101 | "check-blacklisted": "blacklisted-gulp", 102 | "preversion": "node extractapi.js > docs/API.adoc && asciidoctor -b docbook -o - README.adoc | pandoc -o README.md -f docbook -t markdown_github -", 103 | "postversion": "git push && git push --tags", 104 | "prepublish": "gulp docs && gulp dist" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /ritzy-editor.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Bold-Latin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritzyed/ritzy/39e28033cbd4808d90a821a95e9c32aba3195a2c/src/assets/fonts/OpenSans-Bold-Latin.ttf -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-BoldItalic-Latin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritzyed/ritzy/39e28033cbd4808d90a821a95e9c32aba3195a2c/src/assets/fonts/OpenSans-BoldItalic-Latin.ttf -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Italic-Latin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritzyed/ritzy/39e28033cbd4808d90a821a95e9c32aba3195a2c/src/assets/fonts/OpenSans-Italic-Latin.ttf -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Regular-Latin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritzyed/ritzy/39e28033cbd4808d90a821a95e9c32aba3195a2c/src/assets/fonts/OpenSans-Regular-Latin.ttf -------------------------------------------------------------------------------- /src/assets/fonts/README.adoc: -------------------------------------------------------------------------------- 1 | = Font Assets README 2 | 3 | == Purpose == 4 | 5 | These font assets are loaded by 6 | http://nodebox.github.io/opentype.js/[OpenType.js] for calculating font metrics. 7 | OpenType.js requires OpenType (.otf) or TrueType (.ttf) font files. 8 | 9 | == Google Fonts == 10 | 11 | Truetype files for Google fonts can be found on the 12 | https://github.com/google/fonts[Google Fonts GitHub repository]. To get subsets of font 13 | files (e.g. latin script only) use Google Fonts online to determine the CSS URL e.g. 14 | http://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,700,400 15 | 16 | Using a user-agent switcher, select Safari for Windows, and visit the URL above. The fonts 17 | chosen will be presented in TrueType format suitable for use by OpenType.js. 18 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * client.js is not directly consumed by users of the Ritzy module – it is meant as an example and for development only. 3 | * Consumers of this module should either import Ritzy (as shown below), or use the Editor React component directly. 4 | */ 5 | import 'babel/polyfill' 6 | import Ritzy from './ritzy' 7 | 8 | // font support is baking (so configuration is left at the OpenSans default) 9 | // the most often used config values are shown below 10 | 11 | const config = { 12 | id: '10', 13 | fontSize: 18, 14 | width: 600, 15 | margin: {horizontal: 30, vertical: 35}, 16 | // userId: ..., 17 | // userName: ..., 18 | //renderOptimizations: false, 19 | debugEditor: true 20 | } 21 | 22 | const renderTarget = document.getElementById('content') 23 | 24 | let ritzy = new Ritzy(config, renderTarget) 25 | 26 | /* 27 | // for debugging, log all of the events raised by the browser 28 | // The event-emitter API can also be used directly e.g. ritzy.on('position-change', callback) 29 | ritzy.onPositionChange(function(position) { 30 | console.log('event: position-change = ', position) 31 | }) 32 | 33 | ritzy.onSelectionChange(function(selection) { 34 | console.log('event: selection-change = ', selection) 35 | }) 36 | 37 | ritzy.onFocusGained(function() { 38 | console.log('event: focus-gained') 39 | }) 40 | 41 | ritzy.onFocusLost(function() { 42 | console.log('event: focus-lost') 43 | }) 44 | 45 | ritzy.onRemoteCursorAdd(function(remoteCursor) { 46 | console.log('event: remote-cursor-add', remoteCursor) 47 | }) 48 | 49 | ritzy.onRemoteCursorRemove(function(remoteCursor) { 50 | console.log('event: remote-cursor-remove', remoteCursor) 51 | }) 52 | 53 | ritzy.onRemoteCursorChangeName(function(remoteCursor, oldName, newName) { 54 | console.log('event: remote-cursor-change-name', remoteCursor, oldName, newName) 55 | }) 56 | 57 | ritzy.onTextInsert(function(atPosition, value, attributes, newPosition) { 58 | console.log('event: text-insert atPosition=', atPosition, 'value=', value, 'attributes=', attributes, 'newPosition=', newPosition) 59 | }) 60 | 61 | ritzy.onTextDelete(function(from, to, newPosition) { 62 | console.log('event: text-delete from=', from, 'to=', to, 'newPosition=', newPosition) 63 | }) 64 | */ 65 | 66 | ritzy.load((err) => { 67 | document.getElementById('content').innerHTML = 'Oops, I couldn\'t load the editor:\n\n' + err 68 | }) 69 | 70 | // for API accessibility in the console for debugging 71 | window.ritzy = ritzy 72 | -------------------------------------------------------------------------------- /src/components/Cursor.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import React from 'react/addons' 3 | import classNames from 'classnames' 4 | 5 | import EditorActions from '../flux/EditorActions' 6 | import { ATTR } from '../core/attributes' 7 | import { scrollByToVisible } from '../core/dom' 8 | import ReactUtils from '../core/ReactUtils' 9 | 10 | const T = React.PropTypes 11 | 12 | export default React.createClass({ 13 | propTypes: { 14 | cursorPosition: T.object.isRequired, 15 | lineHeight: T.number.isRequired, 16 | cursorMotion: T.bool, 17 | activeAttributes: T.object, 18 | selectionActive: T.bool, 19 | focus: T.bool, 20 | remoteNameReveal: T.bool, 21 | remote: T.object, 22 | renderOptimizations: T.bool 23 | }, 24 | 25 | getDefaultProps() { 26 | return { 27 | remoteNameReveal: false, 28 | remote: null, 29 | renderOptimizations: true 30 | } 31 | }, 32 | 33 | shouldComponentUpdate(nextProps) { 34 | if(!nextProps.renderOptimizations) { 35 | return true 36 | } 37 | 38 | // for better performance make sure objects are immutable so that we can do reference equality checks 39 | let propsEqual = this.props.lineHeight === nextProps.lineHeight 40 | && this.props.cursorMotion === nextProps.cursorMotion 41 | && this.props.selectionActive === nextProps.selectionActive 42 | && this.props.focus === nextProps.focus 43 | && ReactUtils.deepEquals(this.props.remoteNameReveal, nextProps.remoteNameReveal) 44 | && ReactUtils.deepEquals(this.props.cursorPosition, nextProps.cursorPosition) 45 | && ReactUtils.deepEquals(this.props.activeAttributes, nextProps.activeAttributes) 46 | && ReactUtils.deepEquals(this.props.remote, nextProps.remote, _.isEqual, [r => r.color, r => r.name, r => r.state]) 47 | 48 | return !propsEqual 49 | }, 50 | 51 | componentDidUpdate() { 52 | if(!this.caret) { 53 | this.caret = React.findDOMNode(this.refs.caret) 54 | } 55 | if(this.caret) { 56 | let scrollByToCursor = scrollByToVisible(this.caret, 5, 30) 57 | if(scrollByToCursor.xDelta !== 0 || scrollByToCursor.yDelta !== 0) { 58 | window.scrollBy(scrollByToCursor.xDelta, scrollByToCursor.yDelta) 59 | } 60 | } 61 | }, 62 | 63 | _remoteCursorHover(e) { 64 | EditorActions.revealRemoteCursorName(this.props.remote) 65 | 66 | e.preventDefault() 67 | e.stopPropagation() 68 | }, 69 | 70 | render() { 71 | //console.trace('render Cursor', this.props) 72 | let cursorPosition = this.props.cursorPosition 73 | let remote = this.props.remote 74 | 75 | // the initial render before the component is mounted has no position 76 | if (!cursorPosition) { 77 | return null 78 | } 79 | 80 | let cursorClasses = classNames('ritzy-internal-text-cursor text-cursor', 'ritzy-internal-ui-unprintable', { 81 | 'ritzy-internal-text-cursor-blink': !this.props.cursorMotion && !remote 82 | }) 83 | 84 | let italicAtPosition = cursorPosition.position.attributes && cursorPosition.position.attributes[ATTR.ITALIC] && !remote 85 | let italicActive = this.props.activeAttributes && this.props.activeAttributes[ATTR.ITALIC] && !remote 86 | let italicInactive = this.props.activeAttributes && !this.props.activeAttributes[ATTR.ITALIC] && !remote 87 | 88 | let caretClasses = classNames('ritzy-internal-text-cursor-caret text-cursor-caret', { 89 | 'ritzy-internal-text-cursor-italic': italicActive || (italicAtPosition && !italicInactive) 90 | }) 91 | 92 | let cursorStyle = { 93 | left: cursorPosition.left, 94 | top: cursorPosition.top 95 | } 96 | 97 | if (!remote && (this.props.selectionActive || !this.props.focus)) { 98 | cursorStyle.opacity = 0 99 | cursorStyle.visibility = 'hidden' 100 | } else { 101 | cursorStyle.opacity = 1 102 | } 103 | 104 | let cursorHeight = Math.round(this.props.lineHeight * 10) / 10 105 | 106 | if(remote) { 107 | let cursorTopStyle = { 108 | backgroundColor: remote.color, 109 | opacity: 1 110 | } 111 | let cursorNameStyle = { 112 | backgroundColor: remote.color 113 | } 114 | if(this.props.remoteNameReveal) { 115 | cursorTopStyle.display = 'none' 116 | cursorNameStyle.opacity = 1 117 | } else { 118 | cursorNameStyle.opacity = 0 119 | cursorNameStyle.display = 'none' 120 | } 121 | 122 | return ( 123 |
124 |
125 |
126 |
{remote.name}
127 |
128 | ) 129 | } else { 130 | return ( 131 |
132 |
133 |
134 |
135 |
136 | ) 137 | } 138 | } 139 | 140 | }) 141 | -------------------------------------------------------------------------------- /src/components/DebugEditor.js: -------------------------------------------------------------------------------- 1 | import React from 'react/addons' 2 | 3 | import EditorActions from '../flux/EditorActions' 4 | import EditorStore from '../flux/EditorStore' 5 | import { BASE_CHAR } from '../core/RichText' 6 | import { lineContainingChar } from '../core/EditorCommon' 7 | import { logInGroup } from '../core/utils' 8 | 9 | const T = React.PropTypes 10 | 11 | export default React.createClass({ 12 | propTypes: { 13 | editorState: T.object, 14 | replica: T.object, 15 | searchLinesWithSelection: T.func, 16 | setRenderOptimizations: T.func 17 | }, 18 | 19 | getInitialState() { 20 | return { 21 | autotypeText: '' 22 | } 23 | }, 24 | 25 | componentWillReceiveProps(nextProps) { 26 | this.edState = nextProps.editorState 27 | this.replica = nextProps.replica 28 | }, 29 | 30 | shouldComponentUpdate(nextProps, nextState) { 31 | return this.state.autotypeSeconds !== nextState.autotypeSeconds 32 | || this.state.autotypeMinutes !== nextState.autotypeMinutes 33 | || this.state.autotypeText !== nextState.autotypeText 34 | }, 35 | 36 | _dumpState() { 37 | console.debug('Current state contents (Use React Devtools 0.14+ for real-time state view/edit):') 38 | console.dir(this.edState) 39 | EditorActions.focusInput() 40 | }, 41 | 42 | _dumpReplica() { 43 | let text = this.replica.getTextRange(BASE_CHAR) 44 | console.debug('Current replica text: [' + text.map(c => c.char).join('') + ']') 45 | console.debug('BASE_CHAR:') 46 | console.dir(this.replica.getCharAt(0)) 47 | console.debug('Current replica contents:') 48 | console.dir(text) 49 | EditorActions.focusInput() 50 | }, 51 | 52 | _dumpPosition() { 53 | if(this.edState.position) { 54 | console.debug('Current position:', this.edState.position, 'positionEolStart:', this.edState.positionEolStart) 55 | } else { 56 | console.debug('No active position') 57 | } 58 | EditorActions.focusInput() 59 | }, 60 | 61 | _dumpCurrentLine() { 62 | logInGroup('Line debug', () => { 63 | if(this.edState.lines) { 64 | let printLine = l => console.debug(l.toString()) 65 | 66 | let currentLine = lineContainingChar(this.edState.lines, this.edState.position, this.edState.positionEolStart) 67 | if(!currentLine) { 68 | console.log(null) 69 | } else { 70 | if (currentLine.index > 0) { 71 | logInGroup('Before', () => { 72 | printLine(this.edState.lines[currentLine.index - 1]) 73 | }) 74 | } 75 | logInGroup('Current', () => { 76 | console.debug('index', currentLine.index, 'endOfLine', currentLine.endOfLine) 77 | printLine(currentLine.line) 78 | }) 79 | if (currentLine.index < this.edState.lines.length - 1) { 80 | logInGroup('After', () => { 81 | printLine(this.edState.lines[currentLine.index + 1]) 82 | }) 83 | } 84 | } 85 | } else { 86 | console.debug('No lines') 87 | } 88 | }) 89 | EditorActions.focusInput() 90 | }, 91 | 92 | _dumpLines() { 93 | if(this.edState.lines) { 94 | console.debug('Lines as Objects:', this.edState.lines) 95 | this._dumpLinesFormatted('Lines', this.edState.lines) 96 | } else { 97 | console.debug('No lines') 98 | } 99 | EditorActions.focusInput() 100 | }, 101 | 102 | _dumpSelection() { 103 | if(this.edState.selectionActive) { 104 | console.debug('Current selection contents (rich chunks): [' + JSON.stringify(EditorStore.getSelectionRich()) + ']') 105 | console.debug('Current selection contents (plain): [' + EditorStore.getSelectionText() + ']') 106 | console.debug('Current selection contents (html): [' + EditorStore.getSelectionHtml() + ']') 107 | console.debug('Left=', this.edState.selectionLeftChar) 108 | console.debug('Right=', this.edState.selectionRightChar) 109 | console.debug('Anchor=', this.edState.selectionAnchorChar) 110 | console.debug('Chars=', EditorStore.getSelection()) 111 | } else { 112 | console.debug('No active selection') 113 | } 114 | EditorActions.focusInput() 115 | }, 116 | 117 | _dumpLinesWithSelection() { 118 | let linesWithSelection = this.props.searchLinesWithSelection() 119 | if(linesWithSelection) { 120 | let lines = this.edState.lines.slice(linesWithSelection.left, linesWithSelection.right + 1) 121 | console.debug('Lines with selection as Objects:', lines) 122 | this._dumpLinesFormatted('Lines with selection', lines) 123 | } else { 124 | console.debug('No selected lines') 125 | } 126 | EditorActions.focusInput() 127 | }, 128 | 129 | _dumpLinesFormatted(logText, lines) { 130 | logInGroup(logText, () => { 131 | for(let i = 0; i < lines.length; i++) { 132 | logInGroup(`Index ${i}`, () => { // eslint-disable-line no-loop-func 133 | console.debug(lines[i].toString()) 134 | }) 135 | } 136 | }) 137 | }, 138 | 139 | _forceFlow() { 140 | EditorActions.replicaUpdated() 141 | EditorActions.focusInput() 142 | }, 143 | 144 | _forceRender() { 145 | this.forceUpdate(() => console.debug('Render done.')) 146 | EditorActions.focusInput() 147 | }, 148 | 149 | _togglePositionEolStart() { 150 | // state should only be set from the store, but for debugging this is fine 151 | this.setState(previousState => { 152 | let previous = previousState.positionEolStart 153 | console.debug('Toggling positionEolStart from ' + previous + ' to ' + !previous) 154 | return { positionEolStart: !previous } 155 | }) 156 | EditorActions.focusInput() 157 | }, 158 | 159 | _testError() { 160 | let err = new Error('A test error from DebugEditor') 161 | EditorActions.registerEditorError(err) 162 | }, 163 | 164 | _renderOptimizationsEnable() { 165 | this.props.setRenderOptimizations(true) 166 | }, 167 | 168 | _renderOptimizationsDisable() { 169 | this.props.setRenderOptimizations(false) 170 | }, 171 | 172 | _scheduleAutotype() { 173 | function at(minutes, seconds, cb) { 174 | (function loop() { 175 | let now = new Date() 176 | if (now.getMinutes() === minutes) { 177 | if(now.getSeconds() >= seconds) { 178 | cb() 179 | } else { 180 | let delay = 1000 - (new Date() % 1000) 181 | setTimeout(loop, delay) 182 | } 183 | } else { 184 | let delay = 60000 - (new Date() % 60000) 185 | setTimeout(loop, delay) 186 | } 187 | })() 188 | } 189 | 190 | let text = this.state.autotypeText.split('') 191 | if(text.length === 0 || !this.state.autotypeMinutes || !this.state.autotypeSeconds) { 192 | console.debug('No autotype, invalid input.') 193 | return 194 | } 195 | console.debug(`Scheduling autotype of chars [${text}] @ ${this.state.autotypeMinutes} minutes, ${this.state.autotypeSeconds} seconds.`) 196 | at(this.state.autotypeMinutes, this.state.autotypeSeconds, () => { 197 | // if there is any debug logging it will be grouped 198 | logInGroup(`Autotyping text ${text}`, () => { 199 | // auto-type one char at a time for the hardest concurrency test possible 200 | for(let i = 0; i < text.length; i++) { 201 | EditorActions.insertChars(text[i]) 202 | } 203 | }) 204 | }) 205 | EditorActions.focusInput() 206 | }, 207 | 208 | _onChangeAutotypeText(e) { 209 | this.setState({autotypeText: e.target.value}) 210 | }, 211 | 212 | _onChangeAutotypeMinutes(e) { 213 | let value = parseInt(e.target.value) 214 | if(!Number.isNaN(value)) { 215 | this.setState({autotypeMinutes: parseInt(e.target.value)}) 216 | } 217 | }, 218 | 219 | _onChangeAutotypeSeconds(e) { 220 | let value = parseInt(e.target.value) 221 | if(!Number.isNaN(value)) { 222 | this.setState({autotypeSeconds: parseInt(e.target.value)}) 223 | } 224 | }, 225 | 226 | render() { 227 | return ( 228 |
229 |

Debugging tools:

230 | Dump:  231 |   232 |   233 |   234 |   235 |   236 |   237 |
238 | Force:  239 |   240 |
241 | Action:  242 |   243 |
244 | Autotype:  245 |  @  246 | : 247 |  (min:sec)  248 |
249 | Render Optimizations:  250 |   251 |
252 |
253 | ) 254 | } 255 | 256 | }) 257 | -------------------------------------------------------------------------------- /src/components/EditorLine.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import React from 'react/addons' 3 | 4 | import EditorLineContent from './EditorLineContent' 5 | import SelectionOverlay from './SelectionOverlay' 6 | import { linesEq } from '../core/EditorCommon' 7 | import ReactUtils from '../core/ReactUtils' 8 | 9 | const T = React.PropTypes 10 | 11 | export default React.createClass({ 12 | propTypes: { 13 | line: T.object, 14 | lineHeight: T.number.isRequired, 15 | fontSize: T.number.isRequired, 16 | selection: T.object, 17 | remoteSelections: T.arrayOf(T.object), 18 | renderOptimizations: T.bool 19 | }, 20 | 21 | getDefaultProps() { 22 | return { 23 | renderOptimizations: true 24 | } 25 | }, 26 | 27 | shouldComponentUpdate(nextProps) { 28 | if(!nextProps.renderOptimizations) { 29 | return true 30 | } 31 | 32 | // for better performance make sure objects are immutable so that we can do reference equality checks 33 | let propsEqual = this.props.lineHeight === nextProps.lineHeight 34 | && this.props.fontSize === nextProps.fontSize 35 | && ReactUtils.deepEquals(this.props.selection, nextProps.selection) 36 | && ReactUtils.deepEquals(this.props.remoteSelections, nextProps.remoteSelections) 37 | && ReactUtils.deepEquals(this.props.line, nextProps.line, linesEq) 38 | 39 | return !propsEqual 40 | }, 41 | 42 | _renderSelectionOverlay(selection) { 43 | if(!selection) { 44 | return null 45 | } 46 | return ( 47 | 49 | ) 50 | }, 51 | 52 | _renderRemoteSelectionOverlays(remoteSelections) { 53 | if(!remoteSelections) { 54 | return null 55 | } 56 | return remoteSelections.map(s => this._renderSelectionOverlay(s)) 57 | }, 58 | 59 | render() { 60 | //console.trace('render EditorLine') 61 | return ( 62 |
63 | {this._renderSelectionOverlay(this.props.selection)} 64 | {this._renderRemoteSelectionOverlays(this.props.remoteSelections)} 65 | 66 |
67 | ) 68 | } 69 | 70 | }) 71 | -------------------------------------------------------------------------------- /src/components/EditorLineContent.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import React from 'react/addons' 3 | import classNames from 'classnames' 4 | 5 | import { ATTR, hasAttributeFor } from '../core/attributes' 6 | import { linesEq } from '../core/EditorCommon' 7 | import ReactUtils from '../core/ReactUtils' 8 | import TextFontMetrics from '../core/TextFontMetrics' 9 | 10 | const T = React.PropTypes 11 | const nbsp = String.fromCharCode(160) 12 | 13 | export default React.createClass({ 14 | propTypes: { 15 | line: T.object, 16 | fontSize: T.number.isRequired, 17 | renderOptimizations: T.bool 18 | }, 19 | 20 | getDefaultProps() { 21 | return { 22 | renderOptimizations: true 23 | } 24 | }, 25 | 26 | shouldComponentUpdate(nextProps) { 27 | if(!nextProps.renderOptimizations) { 28 | return true 29 | } 30 | 31 | // for better performance make sure objects are immutable so that we can do reference equality checks 32 | let propsEqual = this.props.fontSize === nextProps.fontSize 33 | && ReactUtils.deepEquals(this.props.line, nextProps.line, linesEq) 34 | 35 | return !propsEqual 36 | }, 37 | 38 | _renderStyledText(id, text, attributes) { 39 | let hasAttribute = hasAttributeFor(attributes) 40 | 41 | // vertical alignment 42 | let superscript = hasAttribute(ATTR.SUPERSCRIPT) 43 | let subscript = hasAttribute(ATTR.SUBSCRIPT) 44 | let verticalAlign = classNames({ 45 | super: superscript, 46 | sub: subscript, 47 | baseline: !(superscript || subscript) 48 | }) 49 | 50 | // font size, weight, style 51 | let fontSize = TextFontMetrics.fontSizeFromAttributes(this.props.fontSize, attributes) 52 | let fontWeight = hasAttribute(ATTR.BOLD) ? 'bold' : 'normal' 53 | let fontStyle = hasAttribute(ATTR.ITALIC) ? 'italic' : 'normal' 54 | 55 | // text-decoration 56 | let underline = hasAttribute(ATTR.UNDERLINE) 57 | let strikethrough = hasAttribute(ATTR.STRIKETHROUGH) 58 | let textDecoration = classNames({ 59 | none: !(underline || strikethrough), 60 | underline: underline, 61 | 'line-through': strikethrough 62 | }) 63 | 64 | let style = { 65 | color: '#000000', 66 | backgroundColor: 'transparent', 67 | fontFamily: 'Open Sans', // TODO test other fonts, make the font selectable 68 | fontSize: fontSize, 69 | fontWeight: fontWeight, 70 | fontStyle: fontStyle, 71 | fontVariant: 'normal', 72 | textDecoration: textDecoration, 73 | verticalAlign: verticalAlign 74 | } 75 | 76 | return ( 77 | {text} 78 | ) 79 | }, 80 | 81 | _splitIntoChunks(line) { 82 | if(!line) return [] 83 | 84 | return line.chunks.map(chunk => { 85 | let chars = line.chars.slice(chunk.start, chunk.end) 86 | return this._renderStyledText(chars[0].id, chars.map(c => c.char === ' ' ? nbsp : c.char).join(''), chunk.attributes) 87 | }) 88 | }, 89 | 90 | render() { 91 | //console.trace('render EditorLine') 92 | let line = this.props.line 93 | let blockHeight = 10000 94 | let blockTop = TextFontMetrics.top(this.props.fontSize) - blockHeight 95 | 96 | return ( 97 |
98 | 99 | 100 | {this._splitIntoChunks(line)} 101 | 102 |
103 | ) 104 | } 105 | 106 | }) 107 | -------------------------------------------------------------------------------- /src/components/SelectionOverlay.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import React from 'react/addons' 3 | 4 | import ReactUtils from '../core/ReactUtils' 5 | 6 | const T = React.PropTypes 7 | 8 | export default React.createClass({ 9 | propTypes: { 10 | selection: T.object, 11 | renderOptimizations: T.bool 12 | }, 13 | 14 | getDefaultProps() { 15 | return { 16 | renderOptimizations: true 17 | } 18 | }, 19 | 20 | shouldComponentUpdate(nextProps) { 21 | if(!nextProps.renderOptimizations) { 22 | return true 23 | } 24 | 25 | // for better performance make sure objects are immutable so that we can do reference equality checks 26 | let propsEqual = ReactUtils.deepEquals(this.props.selection, nextProps.selection) 27 | return !propsEqual 28 | }, 29 | 30 | render() { 31 | //console.trace('render SelectionOverlay') 32 | let selection = this.props.selection 33 | if(!selection) { 34 | return null 35 | } 36 | 37 | let selectionStyle = { 38 | top: 0, 39 | left: selection.left, 40 | width: selection.width, 41 | height: selection.height 42 | } 43 | 44 | if(selection.color) { 45 | selectionStyle.borderTopColor = selection.color 46 | selectionStyle.borderBottomColor = selection.color 47 | selectionStyle.backgroundColor = selection.color 48 | selectionStyle.opacity = 0.15 49 | selectionStyle.color = selection.color 50 | } 51 | 52 | return ( 53 |
55 | ) 56 | } 57 | 58 | }) 59 | -------------------------------------------------------------------------------- /src/components/SharedCursorMixin.js: -------------------------------------------------------------------------------- 1 | import EditorActions from '../flux/EditorActions' 2 | import { extractInternal } from '../core/CursorModel' 3 | 4 | export default { 5 | createSharedCursor() { 6 | let Swarm = this.swarmClient.Swarm 7 | 8 | this.cursorId = this.props.id + '_' + this.swarmClient.id 9 | let cursorSet = new Swarm.CursorSet(this.props.id) 10 | 11 | this.trackedCursors = {} 12 | 13 | cursorSet.on('.init', this.onCursorSetInitialize) 14 | cursorSet.on('.change', this.onCursorSetChange) 15 | }, 16 | 17 | onCursorSetInitialize(spec, value, source) { // eslint-disable-line no-unused-vars 18 | let Swarm = this.swarmClient.Swarm 19 | this.cursorSet = source 20 | this.cursorModel = new Swarm.CursorModel(this.cursorId) 21 | this.cursorModel.on('.init', this.onCursorModelInit) 22 | 23 | source.addObject(this.cursorModel) 24 | // if we lose connection to the server and then gain it again, add the cursor model to the cursor set again 25 | this.swarmClient.addReonHook(() => { 26 | source.addObject(this.cursorModel) 27 | }) 28 | this.swarmClient.addUnloadHook(() => { 29 | clearInterval(this.cursorSetVerificationInterval) 30 | Object.keys(this.trackedCursors).forEach(cursorId => this.unSubscribeRemoteCursor(this.trackedCursors[cursorId])) 31 | source.off('.init', this.onCursorSetInitialize) 32 | source.off('.change', this.onCursorSetChange) 33 | }) 34 | 35 | // setup the initial remote cursors 36 | this._foreignCursorSet(source).forEach(remoteCursorModel => { 37 | this.subscribeRemoteCursor(remoteCursorModel) 38 | }) 39 | 40 | // sometimes the set of remote cursors can get out of sync e.g. maybe events are missed for some reason? 41 | this.cursorSetVerificationInterval = setInterval(() => { 42 | let trackedCursorIds = new Set(Object.keys(this.trackedCursors)) 43 | this._foreignCursorSet(source).forEach(remoteCursorModel => { 44 | if(!trackedCursorIds.has(remoteCursorModel._id)) { 45 | this.subscribeRemoteCursor(remoteCursorModel) 46 | } else { 47 | trackedCursorIds.delete(remoteCursorModel._id) 48 | } 49 | }) 50 | // ids do not exist any more in the set 51 | trackedCursorIds.forEach(id => { 52 | this.unSubscribeRemoteCursor(this.trackedCursors[id]) 53 | }) 54 | }, 5000) 55 | }, 56 | 57 | onCursorSetChange(spec, value, source) { // eslint-disable-line no-unused-vars 58 | Object.keys(value).forEach(objectSpec => { 59 | let op = value[objectSpec] 60 | let remoteCursorModel = this.swarmClient.host.get(objectSpec) 61 | // ignore set changes to our own cursor 62 | if(remoteCursorModel._id !== this.cursorId) { 63 | if(op === 0 && this.trackedCursors[remoteCursorModel._id]) { // delete 64 | this.unSubscribeRemoteCursor(this.trackedCursors[remoteCursorModel._id]) 65 | } else if(op === 1) { // add 66 | this.subscribeRemoteCursor(remoteCursorModel) 67 | } 68 | } 69 | }) 70 | }, 71 | 72 | onCursorModelInit(spec, value, source) { // eslint-disable-line no-unused-vars 73 | this.swarmClient.addUnloadHook(() => { 74 | source.off('.init', this.onCursorModelInit) 75 | }) 76 | 77 | this.swarmClient.addUnloadHook(() => { 78 | this.cursorSet.removeObject(this.cursorModel) 79 | }) 80 | 81 | EditorActions.onCursorModelUpdate(this._cursorModelSet) 82 | }, 83 | 84 | subscribeRemoteCursor(remoteCursorModel) { 85 | remoteCursorModel.on('.init', this.onRemoteCursorInit) 86 | remoteCursorModel.on('.set', this.onRemoteCursorUpdate) 87 | }, 88 | 89 | unSubscribeRemoteCursor(remoteCursor) { 90 | let id = remoteCursor.swarmModel._id 91 | // TODO this doesn't work because swarm.js is looking for the function in listener.sink but it is found in listener.sink.sink 92 | // see https://github.com/gritzko/swarm/blob/master/lib/Syncable.js#L673 93 | remoteCursor.swarmModel.off('.init', this.onRemoteCursorInit) 94 | remoteCursor.swarmModel.off('.set', this.onRemoteCursorUpdate) 95 | let internalModel = this.trackedCursors[id].internalModel 96 | delete this.trackedCursors[id] 97 | EditorActions.unsetRemoteCursorPosition(internalModel) 98 | }, 99 | 100 | onRemoteCursorInit(spec, value, source) { // eslint-disable-line no-unused-vars 101 | let id = source._id 102 | let remoteCursor 103 | // if already subscribed, unsubscribe first (but keep the local color info) --> shouldn't happen but check for it anyway 104 | if(this.trackedCursors[id]) { 105 | remoteCursor = this.trackedCursors[id] 106 | remoteCursor.swarmModel.off('.set', this.onRemoteCursorUpdate) 107 | remoteCursor.swarmModel = source 108 | remoteCursor.internalModel = this._internalModelFromSwarmModel(source, remoteCursor.internalModel.color) 109 | } else { 110 | let usedColors = new Set(Object.values(this.trackedCursors).map(c => c.internalModel.color)) 111 | let possibleColors = this.props.cursorColorSpace.filter(c => !usedColors.has(c)) 112 | // re-use colors if none are left (should be by oldest idle time) 113 | let color = possibleColors.length > 0 ? possibleColors[0] : usedColors[0] 114 | let internalModel = this._internalModelFromSwarmModel(source, color) 115 | remoteCursor = { 116 | swarmModel: source, 117 | internalModel: internalModel 118 | } 119 | this.trackedCursors[id] = remoteCursor 120 | } 121 | EditorActions.setRemoteCursorPosition(remoteCursor.internalModel) 122 | }, 123 | 124 | onRemoteCursorUpdate(spec, value, source) { // eslint-disable-line no-unused-vars 125 | let id = source._id 126 | if(!id || !this.trackedCursors[id]) return 127 | 128 | let internalModel = this._internalModelFromSwarmModel(this.trackedCursors[id].swarmModel, 129 | this.trackedCursors[id].internalModel.color) 130 | 131 | EditorActions.setRemoteCursorPosition(internalModel) 132 | }, 133 | 134 | _foreignCursorSet(cursorSet) { 135 | return cursorSet.list().filter(c => c._id && c._id !== this.cursorId) 136 | }, 137 | 138 | _internalModelFromSwarmModel(swarmModel, color) { 139 | let internalModel = extractInternal(swarmModel) 140 | internalModel.color = color 141 | return internalModel 142 | }, 143 | 144 | _cursorModelSet(updatedModel) { 145 | if(this.cursorModel._lstn.length < 2) { 146 | console.warn('The cursor model seems to have lost its connection to the server (Swarm.js bug?). ' + 147 | 'Attempting to recreate it. If you can reproduce this reliably, please report your repro recipe to ' + 148 | 'https://github.com/ritzyed/ritzy/issues.') 149 | // no need to set the updated model here, the latest will be set by the store on the .init 150 | this._reinitCursorModel() 151 | return 152 | } 153 | 154 | // do the remote work at the end of the event queue to avoid UI latency 155 | setTimeout(() => { 156 | // add the cursor model back into the set if it is not there (reaped due to idleness?) 157 | if (this.cursorSet.list().findIndex(e => e._id === this.cursorModel._id) < 0) { 158 | this.cursorSet.addObject(this.cursorModel) 159 | } 160 | this.cursorModel.set(updatedModel) 161 | }, 0) 162 | }, 163 | 164 | _reinitCursorModel() { 165 | this.cursorSet.removeObject(this.cursorModel) 166 | 167 | this.cursorModel = new Swarm.CursorModel(this.cursorId) 168 | this.cursorModel.on('.init', this.onCursorModelInit) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/components/SwarmClientMixin.js: -------------------------------------------------------------------------------- 1 | import SwarmClient from '../core/swarmclient' 2 | 3 | export default { 4 | componentWillMount() { 5 | let swarmClientConfig = { 6 | wsPort: this.props.wsPort 7 | } 8 | this.swarmClient = new SwarmClient(this.props.userId, swarmClientConfig) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/TextReplicaMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | createTextReplica() { 3 | let Text = this.swarmClient.Swarm.Text 4 | this.replica = new Text(this.props.id) 5 | }, 6 | 7 | registerCb(initCb, updateCb) { 8 | this.replica.on('.init', initCb) 9 | this.replica.on('.insert', updateCb) 10 | this.replica.on('.remove', updateCb) 11 | this.replica.on('.setAttributes', updateCb) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/__tests__/Editor-test.js: -------------------------------------------------------------------------------- 1 | /* 2 | import { assert } from 'chai' 3 | import _ from 'lodash' 4 | import Editor from '../Editor' 5 | 6 | describe('Editor component', () => { 7 | it('can render content', () => { 8 | let content = [ 9 | { char: 'a' }, 10 | { char: ' ' }, 11 | { char: 'b', attributes: { strong: true } }, 12 | { char: 'o', attributes: { strong: true } }, 13 | { char: 'l', attributes: { strong: true, em: true } }, 14 | { char: 'd', attributes: { strong: true, em: true } }, 15 | { char: ' ', attributes: { em: true } }, 16 | { char: 'w', attributes: { em: true } }, 17 | { char: 'o', attributes: { em: true } }, 18 | { char: 'r' }, 19 | { char: 'd' } 20 | ] 21 | 22 | 23 | let expected = 'a bold word' 24 | }) 25 | }) 26 | */ 27 | -------------------------------------------------------------------------------- /src/core/CursorModel.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import Model from 'swarm/lib/Model' 3 | 4 | export function extractInternal(swarmModel) { 5 | return _.pick(swarmModel, '_id', 'name', 'state', 'ms') 6 | } 7 | 8 | export default Model.extend('Cursor', { 9 | defaults: { 10 | name: null, 11 | state: { 12 | position: null, 13 | positionEolStart: false, 14 | selectionActive: false, 15 | selectionLeftChar: null, 16 | selectionRightChar: null 17 | }, 18 | ms: Date.now() // activity timestamp 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /src/core/CursorSet.js: -------------------------------------------------------------------------------- 1 | import SyncSet from 'swarm/lib/Set' 2 | 3 | // this collection class has no functionality except for being a list of all cursors currently active 4 | export default SyncSet.extend('CursorSet', {}) 5 | -------------------------------------------------------------------------------- /src/core/EditorCommon.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | import { BASE_CHAR, EOF } from './RichText' 4 | 5 | export function charId(charOrId) { 6 | return _.has(charOrId, 'id') ? charOrId.id : charOrId 7 | } 8 | 9 | /** 10 | * Determines whether two chars are the same or not. 11 | * @param {Char|number} charOrId1 12 | * @param {Char|number} charOrId2 13 | */ 14 | export function charEq(charOrId1, charOrId2) { 15 | if(charOrId1 === charOrId2) return true 16 | if(!charOrId1) return Object.is(charOrId1, charOrId2) 17 | return charId(charOrId1) === charId(charOrId2) 18 | } 19 | 20 | /** 21 | * Represents a Line which is created from the flow algorithm. 22 | */ 23 | export class Line { 24 | /** 25 | * 26 | * @param {Array} chars 27 | * @param {Array} chunks 28 | * @param {Char} start 29 | * @param {Char} end 30 | * @param {number} advance 31 | */ 32 | constructor(chars, chunks, start, end, advance) { 33 | this.chars = chars 34 | this.chunks = chunks 35 | this.start = start 36 | this.end = end 37 | this.advance = advance 38 | } 39 | 40 | hasChar(charOrId) { 41 | if(!charOrId) return false 42 | // lazy evaluation of charIds if necessary 43 | if(!this._charIds) { 44 | this._charIds = new Set() 45 | this.chars.forEach(c => this._charIds.add(c.id)) 46 | } 47 | return this._charIds.has(charId(charOrId)) 48 | } 49 | 50 | /** 51 | * The first character of the line. This is not equal to "start" since start is the *position* at which the 52 | * first character is present (i.e. the previous char === the previous line.end). 53 | * @returns {Char} 54 | */ 55 | first() { 56 | return this.chars.length > 0 ? this.chars[0] : null 57 | } 58 | 59 | toString() { 60 | let summary = '-' 61 | if(this.chars.length > 0) { 62 | let text = this.chars.map(c => c.char === '\n' ? '↵' : c.char) 63 | if(text.length > 13) { 64 | let textBegin = text.slice(0, 5).join('') 65 | let textEnd = text.slice(text.length - 5).join('') 66 | summary = textBegin + '…' + textEnd 67 | } else { 68 | summary = text.join('') 69 | } 70 | } 71 | let first = this.chars.length > 1 ? this.first().toString() : '' 72 | let second = this.chars.length > 2 ? this.chars[1].toString() : '' 73 | let summaryBegin = first.length > 0 && second.length > 0 ? first + ' ' + second : first + second 74 | let penultimate = this.chars.length > 1 ? this.chars[this.chars.length - 2].toString() : '' 75 | let summaryEnd = penultimate.length > 0 ? penultimate + ' ' + this.end.toString() : this.end.toString() 76 | let summaryChars = summaryBegin.length > 0 ? `${summaryBegin} … ${summaryEnd}` : summaryEnd 77 | return `[${summary}] start=${this.start.toString()} chars=[${summaryChars}] adv=${this.advance}}` 78 | } 79 | 80 | isHard() { 81 | return this.end.char === '\n' 82 | } 83 | 84 | isEof() { 85 | return this.end === EOF 86 | } 87 | 88 | indexOf(charOrId) { 89 | for (let i = 0; i < this.chars.length; i++) { 90 | if (charEq(this.chars[i], charOrId)) { 91 | return i 92 | } 93 | } 94 | return -1 95 | } 96 | 97 | /** 98 | * Obtains chars between the given char (exclusive) to the given char (inclusive). Note the 99 | * begin exclusivity operates differently than array slice (which is end exclusive), but corresponds 100 | * generally with how character ranges in the editor are used. 101 | * @param fromCharOrId 102 | * @param toCharOrId 103 | */ 104 | charsBetween(fromCharOrId, toCharOrId) { 105 | let indexFrom = 0 106 | let indexTo = this.chars.length 107 | 108 | if(!charEq(fromCharOrId, this.start)) { 109 | indexFrom = this.indexOf(fromCharOrId) + 1 110 | } 111 | 112 | if(toCharOrId !== EOF && !charEq(toCharOrId, this.end)) { 113 | indexTo = this.indexOf(toCharOrId) + 1 114 | } 115 | 116 | return this.chars.slice(indexFrom, indexTo) 117 | } 118 | 119 | charsTo(charOrId) { 120 | return this.chars.slice(0, this.indexOf(charOrId) + 1) 121 | } 122 | } 123 | 124 | const EMPTY_LINE = new Line([], [], BASE_CHAR, EOF, 0) 125 | 126 | /** 127 | * Determines whether two lines are the same or not i.e. that the lines refer to the same 128 | * chars with the same attributes. Will also return true if both lines are undefined or 129 | * both are null. 130 | * @param line1 131 | * @param line2 132 | */ 133 | export function linesEq(line1, line2) { 134 | if(line1 === line2) return true 135 | if(!line1) return Object.is(line1, line2) 136 | if(_.isArray(line1.chars) && _.isArray(line2.chars) && line1.chars.length !== line2.chars.length) return false 137 | if(!_.isEqual(line1.start, line2.start)) return false 138 | if(!_.isEqual(line1.end, line2.end)) return false 139 | if(!_.isEqual(line1.chunks, line2.chunks)) return false 140 | return _.isEqual(line1.chars, line2.chars) 141 | } 142 | 143 | /** 144 | * Determines whether two char arrays are the same or not i.e. that the arrays refer to the 145 | * same chars (but note that this only checks char identity, and ignores char properties like 146 | * attributes and deleted chars). Will also return true if both arrays are undefined or both 147 | * are null. 148 | * @param {Char[]} cArr1 149 | * @param {Char[]} cArr2 150 | */ 151 | export function charArrayEq(cArr1, cArr2) { 152 | if(cArr1 === cArr2) return true 153 | if(!cArr1 || !_.isArray(cArr1)) return Object.is(cArr1, cArr2) 154 | if(cArr1.length !== cArr2.length) return false 155 | for(let i = 0; i < cArr1.length; i++) { 156 | if(!charEq(cArr1[i], cArr2[i])) return false 157 | } 158 | return true 159 | } 160 | 161 | /** 162 | * Search the given search space for the line containing the provided char. If the search 163 | * space is empty, an empty "virtual" line starting at BASE_CHAR and ending at EOF is returned. 164 | * @param searchSpace The set of lines to search. 165 | * @param charOrId The char or char id to search for. 166 | * @param {boolean} [nextIfEol=false] If at end of line, return the next line. 167 | * @returns {*} 168 | */ 169 | export function lineContainingChar(searchSpace, charOrId, nextIfEol) { 170 | if(_.isUndefined(nextIfEol)) nextIfEol = false 171 | 172 | if(!searchSpace || searchSpace.length === 0) { 173 | return { 174 | line: EMPTY_LINE, 175 | index: -1, 176 | endOfLine: null 177 | } 178 | } 179 | 180 | // shortcut searches at the beginning or end of the searchSpace, this is used often and these comparisons are fast 181 | if(charEq(searchSpace[0].start, charOrId)) { 182 | return { 183 | line: searchSpace[0], 184 | index: 0, 185 | endOfLine: !charEq(charOrId, BASE_CHAR) 186 | } 187 | } else if(charEq(searchSpace[searchSpace.length - 1].end, charOrId)) { 188 | return { 189 | line: searchSpace[searchSpace.length - 1], 190 | index: searchSpace.length - 1, 191 | endOfLine: true 192 | } 193 | } 194 | 195 | for(let i = 0; i < searchSpace.length; i++) { 196 | let line = searchSpace[i] 197 | if(line.hasChar(charOrId)) { 198 | let index = i 199 | let endOfLine = charEq(charOrId, line.end) 200 | if(nextIfEol && endOfLine && !line.isEof() && searchSpace.length - 1 > i) { 201 | index++ 202 | line = searchSpace[index] 203 | } 204 | 205 | return { 206 | line: line, 207 | index: index, 208 | endOfLine: endOfLine 209 | } 210 | } 211 | } 212 | 213 | return null 214 | } 215 | -------------------------------------------------------------------------------- /src/core/ReactUtils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | function deepEquals(obj1, obj2, equalsFunc, propertyAccessors) { 4 | if(!obj1) { 5 | return Object.is(obj1, obj2) 6 | } 7 | 8 | if(!equalsFunc) { 9 | equalsFunc = _.isEqual 10 | } 11 | 12 | if(propertyAccessors) { 13 | for(let i = 0; i < propertyAccessors.length; i++) { 14 | let f = propertyAccessors[i] 15 | if(!equalsFunc(f(obj1), f(obj2))) return false 16 | } 17 | return true 18 | } else { 19 | return equalsFunc(obj1, obj2) 20 | } 21 | } 22 | 23 | export default { 24 | deepEquals: deepEquals 25 | } 26 | -------------------------------------------------------------------------------- /src/core/TextFontMetrics.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import {ATTR, hasAttributeFor} from './attributes' 3 | 4 | const SUPER_SUB_FONT_RATIO = 0.65 // matches MS word according to http://en.wikipedia.org/wiki/Subscript_and_superscript 5 | 6 | function calcFontScale(fontSize, unitsPerEm) { 7 | return 1 / unitsPerEm * fontSize 8 | } 9 | 10 | function calcSuperSubFontSize(fontSize, minFontSize) { 11 | let superSubFontSize = Math.round(fontSize * SUPER_SUB_FONT_RATIO) 12 | return superSubFontSize > minFontSize ? superSubFontSize : minFontSize 13 | } 14 | 15 | function charFontStyle(char) { 16 | let attrs = char.attributes 17 | if(!attrs) return 'regular' 18 | 19 | let bold = false 20 | let italic = false 21 | 22 | if(!_.isUndefined(attrs[ATTR.BOLD])) bold = attrs[ATTR.BOLD] 23 | if(!_.isUndefined(attrs[ATTR.ITALIC])) italic = attrs[ATTR.ITALIC] 24 | 25 | if(bold && italic) return 'boldItalic' 26 | else if(bold) return 'bold' 27 | else if(italic) return 'italic' 28 | else return 'regular' 29 | } 30 | 31 | function calcFontSizeFromAttributes(fontSize, minFontSize, attributes) { 32 | let hasAttribute = hasAttributeFor(attributes) 33 | 34 | // superscript and subscript affect the font size 35 | let superscript = hasAttribute(ATTR.SUPERSCRIPT) 36 | let subscript = hasAttribute(ATTR.SUBSCRIPT) 37 | 38 | return superscript || subscript ? calcSuperSubFontSize(fontSize, minFontSize) : fontSize 39 | } 40 | 41 | function fontSpec(fontSize, font) { 42 | let styleSpec = font.styleName === 'Regular' ? '' : `${font.styleName} ` 43 | let fontSizeSpec = `${fontSize}px ` 44 | let name = font.familyName 45 | return styleSpec + fontSizeSpec + name 46 | } 47 | 48 | function calcCharAdvanceOpenType(char, fontSize, font, unitsPerEm) { 49 | let glyph = font.charToGlyph(char) 50 | return glyph.unicode ? 51 | glyph.advanceWidth * calcFontScale(fontSize, unitsPerEm) : 52 | // font doesn't contain a glyph for this char, fallback to canvas measurement 53 | calcTextAdvanceCanvas(char, fontSize, font) 54 | } 55 | 56 | let canvas = _.memoize(function() { 57 | return document.createElement('canvas') 58 | }) 59 | 60 | let canvasContext = _.memoize(function() { 61 | return canvas().getContext('2d') 62 | }) 63 | 64 | function clearCanvas() { 65 | let c = canvas() 66 | canvasContext().clearRect(0, 0, c.width, c.height) 67 | } 68 | 69 | function calcTextAdvanceCanvas(text, fontSize, font) { 70 | // need to override newline handling, measureText doesn't handle it correctly (returns a non-zero width) 71 | if(text === '\n') { 72 | return 0 73 | } 74 | let context = canvasContext() 75 | context.font = fontSpec(fontSize, font) 76 | return context.measureText(text).width 77 | } 78 | 79 | /** 80 | * Tests various string widths and compares the advance width (in pixels) results between OpenType.js and 81 | * the canvas fallback mechanism which returns the browser's actual rendered font width. 82 | * @type {Function} 83 | */ 84 | let isOpenTypeJsReliable = _.memoize(function(fontSize, font, unitsPerEm) { 85 | let strings = [ 86 | '111111111111111111111111111111', 87 | 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 88 | 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiii', 89 | 'wwwwwwwwwwwwwwwwwwwwwwwwwwwwww', 90 | 'Lorem ipsum dolor sit amet, libris essent labitur duo cu.' 91 | ] 92 | 93 | let reduceOt = function(currentAdvance, char) { 94 | return currentAdvance + calcCharAdvanceOpenType(char, fontSize, font, unitsPerEm) 95 | } 96 | 97 | let reduceCanvas = function(currentAdvance, char) { 98 | return currentAdvance + calcTextAdvanceCanvas(char, fontSize, font) 99 | } 100 | 101 | let reliable = true 102 | for(let candidate of strings) { 103 | let chars = candidate.split('') 104 | let advanceOt = chars.reduce(reduceOt, 0) 105 | let advanceCanvas = calcTextAdvanceCanvas(candidate, fontSize, font) 106 | let delta = Math.abs(advanceOt - advanceCanvas) 107 | 108 | if(delta > 1) { 109 | console.warn(`OpenType.js NOT reliable on this browser/OS (or font not loaded): 110 | Candidate = [${candidate}], Fontspec = ${fontSpec(fontSize, font)}, Δ = ${delta}px 111 | Falling back to slower canvas measurement mechanism.`) 112 | 113 | // test if canvas char-by-char width additions are the same as canvas total text width 114 | // if this ever returns false, then the current approach will need to be refactored, see docs on calcCharAdvance 115 | let advanceCanvasByChar = chars.reduce(reduceCanvas, 0) 116 | let deltaCanvas = Math.abs(advanceCanvas - advanceCanvasByChar) 117 | 118 | if(deltaCanvas > 1) { 119 | console.error(`Canvas char-by-char width != canvas text width, oops! 120 | Candidate = [${candidate}], Fontspec = ${fontSpec(fontSize, font)}, Δ ot = ${delta}px, Δ canvas = ${deltaCanvas}px 121 | Please report this along with your browser/OS details.`) 122 | } 123 | 124 | reliable = false 125 | break 126 | } 127 | } 128 | 129 | // clear the canvas, not really necessary as measureText shouldn't write anything there 130 | clearCanvas() 131 | return reliable 132 | }, (fontSize, font, unitsPerEm) => fontSpec(fontSize, font) + ' ' + unitsPerEm) 133 | 134 | /** 135 | * Calculate the advance in pixels for a given char. In some browsers/platforms/font sizes, the fonts are not 136 | * rendered according to the specs in the font (see 137 | * http://stackoverflow.com/questions/30922573/firefox-rendering-of-opentype-font-does-not-match-the-font-specification). 138 | * Therefore, ensure the font spec matches the actual rendered width (via the canvas `measureText` method), and use 139 | * the font spec if it matches, otherwise fall back to the (slower) measuredText option. 140 | * 141 | * NOTE there may still be one difference between the browser's rendering and canvas-based calculations here: the 142 | * browser renders entire strings within elements, whereas this calculation renders one character to the canvas at 143 | * a time and adds up the widths. These two approaches seem to be equivalent except for IE in compatibility mode. 144 | * 145 | * TODO refactor mixin to deal with chunks of styled text rather than chars for IE in compatibility mode 146 | */ 147 | function calcCharAdvance(char, fontSize, font, unitsPerEm) { 148 | return isOpenTypeJsReliable(fontSize, font, unitsPerEm) ? 149 | calcCharAdvanceOpenType(char, fontSize, font, unitsPerEm) : 150 | calcTextAdvanceCanvas(char, fontSize, font) 151 | } 152 | 153 | function calcReplicaCharAdvance(replicaChar, fontSize, fonts, minFontSize, unitsPerEm) { 154 | let style = charFontStyle(replicaChar) 155 | let charFontSize = calcFontSizeFromAttributes(fontSize, minFontSize, replicaChar.attributes) 156 | return calcCharAdvance(replicaChar.char, charFontSize, fonts[style], unitsPerEm) 157 | } 158 | 159 | export default { 160 | setConfig(config) { 161 | this.config = config 162 | }, 163 | 164 | /** 165 | * Get the font scale to convert between font units and pixels for the given font size. 166 | * @param fontSize 167 | * @return {number} 168 | */ 169 | fontScale(fontSize) { 170 | return calcFontScale(fontSize, this.config.unitsPerEm) 171 | }, 172 | 173 | /** 174 | * Return the font size given the default font size and current attributes. 175 | * @param fontSize 176 | * @param attributes 177 | */ 178 | fontSizeFromAttributes(fontSize, attributes) { 179 | return calcFontSizeFromAttributes(fontSize, this.config.minFontSize, attributes) 180 | }, 181 | 182 | /** 183 | * Determines the advance for a given replica char. 184 | * @param char The replica char object. 185 | * @param fontSize 186 | * @return {number} 187 | */ 188 | replicaCharAdvance(char, fontSize) { 189 | return calcReplicaCharAdvance(char, fontSize, this.config.fonts, this.config.minFontSize, this.config.unitsPerEm) 190 | }, 191 | 192 | /** 193 | * Determines the advance for a given char. Since it is not a replica char, the font style and other attribute 194 | * information cannot be determined. A normal weight, non-decorated font with no special attributes is assumed. 195 | */ 196 | charAdvance(char, fontSize, font) { 197 | return calcCharAdvance(char, fontSize, font, this.config.unitsPerEm) 198 | }, 199 | 200 | /** 201 | * Returns the advance width in pixels for a space character in the normal style. 202 | */ 203 | advanceXForSpace(fontSize) { 204 | return this.charAdvance(' ', fontSize, this.config.fonts.regular) 205 | }, 206 | 207 | /** 208 | * Obtain an Object with the char id and cursor position for a given pixel value. This is used to 209 | * set the current character and position the cursor correctly on a mouse click. If the target position 210 | * is past the last character, the index of the last character is returned. 211 | * @param {number} fontSize 212 | * @param {number} pixelValue 213 | * @param {Array} chars The characters used to compare against the given pixel value. 214 | * @return {Object} The cursor x position (cursorX) between characters, and the 0-based character index 215 | * (index) for the given x value. 216 | */ 217 | indexAndCursorForXValue(fontSize, pixelValue, chars) { 218 | let minFontSize = this.config.minFontSize 219 | fontSize = fontSize > minFontSize ? fontSize : minFontSize 220 | let currentWidthPx = 0 221 | let index = 0 222 | for(let i = 0; i < chars.length; i++) { 223 | let glyphAdvancePx = this.replicaCharAdvance(chars[i], fontSize) 224 | if(pixelValue < currentWidthPx + glyphAdvancePx / 2) { 225 | return { 226 | cursorX: currentWidthPx, 227 | index: index 228 | } 229 | } else { 230 | currentWidthPx += glyphAdvancePx 231 | if(glyphAdvancePx > 0) index++ 232 | } 233 | } 234 | return { 235 | cursorX: currentWidthPx, 236 | index: index 237 | } 238 | }, 239 | 240 | /** 241 | * Get the advance width in pixels for the given char or chars. 242 | * @param {number} fontSize 243 | * @param {object|Array} chars 244 | * @return {number} 245 | */ 246 | advanceXForChars(fontSize, chars) { 247 | let minFontSize = this.config.minFontSize 248 | fontSize = fontSize > minFontSize ? fontSize : minFontSize 249 | if(_.isArray(chars)) { 250 | return chars.reduce((currentWidthPx, char) => { 251 | return currentWidthPx + this.replicaCharAdvance(char, fontSize) 252 | }, 0) 253 | } else { 254 | return this.replicaCharAdvance(chars, fontSize) 255 | } 256 | }, 257 | 258 | /** 259 | * Gets the line height in pixels for a given font size, using the bold font. 260 | */ 261 | lineHeight(fontSize) { 262 | let fontHeader = this.config.fonts.bold.tables.head 263 | return (fontHeader.yMax - fontHeader.yMin) * this.fontScale(fontSize) 264 | }, 265 | 266 | /** 267 | * Gets the height in pixels of the top of the font, relative to the baseline, using the bold font. 268 | */ 269 | top(fontSize) { 270 | let fontHeader = this.config.fonts.bold.tables.head 271 | return fontHeader.yMax * this.fontScale(fontSize) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/core/__tests__/ReactUtils-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import _ from 'lodash' 3 | import ReactUtils from '../ReactUtils' 4 | 5 | describe('React utils', () => { 6 | it('deep equals compares nulls, Nans, and undefined', () => { 7 | let nullVar = null 8 | let undefinedVar 9 | let nanVar = NaN 10 | 11 | assert.isTrue(ReactUtils.deepEquals(nullVar, nullVar)) 12 | assert.isTrue(ReactUtils.deepEquals(undefinedVar, undefinedVar)) 13 | assert.isTrue(ReactUtils.deepEquals(nanVar, nanVar)) 14 | assert.isFalse(ReactUtils.deepEquals(nullVar, undefinedVar)) 15 | assert.isFalse(ReactUtils.deepEquals(nullVar, nanVar)) 16 | assert.isFalse(ReactUtils.deepEquals(undefinedVar, nanVar)) 17 | }) 18 | 19 | it('compares the same object instances', () => { 20 | let foo1 = {bar: 0} 21 | //noinspection UnnecessaryLocalVariableJS 22 | let foo2 = foo1 23 | 24 | assert.isTrue(ReactUtils.deepEquals(foo1, foo2)) 25 | }) 26 | 27 | it('compares mutated object instances', () => { 28 | let foo1 = {bar: 0} 29 | let foo2 = foo1 30 | foo2.bar = 1 31 | 32 | assert.isTrue(ReactUtils.deepEquals(foo1, foo2)) 33 | }) 34 | 35 | it('compares specific properties that are primitives', () => { 36 | let foo1 = { 37 | a: 0, 38 | b: 1, 39 | c: 2 40 | } 41 | let foo2 = { 42 | a: 1, 43 | b: 1, 44 | c: 3 45 | } 46 | 47 | assert.isFalse(ReactUtils.deepEquals(foo1, foo2)) 48 | assert.isFalse(ReactUtils.deepEquals(foo1, foo2, _.isEqual, [o => o.a, o => o.b])) 49 | assert.isTrue(ReactUtils.deepEquals(foo1, foo2, _.isEqual, [o => o.b])) 50 | }) 51 | 52 | it('compares specific properties that are objects', () => { 53 | let foo1 = { 54 | a: { x: 0, y: 0 }, 55 | b: { x: 0, y: 1 }, 56 | c: { x: 1, y: 0 } 57 | } 58 | let foo2 = { 59 | a: { x: 0, y: 1 }, 60 | b: { x: 0, y: 1 }, 61 | c: { x: 1, y: 0 } 62 | } 63 | 64 | assert.isFalse(ReactUtils.deepEquals(foo1, foo2)) 65 | assert.isFalse(ReactUtils.deepEquals(foo1, foo2, _.isEqual, [o => o.a, o => o.b])) 66 | assert.isTrue(ReactUtils.deepEquals(foo1, foo2, _.isEqual, [o => o.b])) 67 | assert.isTrue(ReactUtils.deepEquals(foo1, foo2, _.isEqual, [o => o.a.x, o => o.b.x])) 68 | }) 69 | 70 | it('compares objects using a provided equality function', () => { 71 | let foo1 = 'bar' 72 | let foo2 = 'baz' 73 | let foo3 = 'gaz' 74 | 75 | assert.isFalse(ReactUtils.deepEquals(foo1, foo2)) 76 | assert.isFalse(ReactUtils.deepEquals(foo2, foo3)) 77 | assert.isFalse(ReactUtils.deepEquals(foo1, foo3)) 78 | 79 | 80 | let compareIgnoreFirst = (o1, o2) => o1.substring(1) === o2.substring(1) 81 | assert.isFalse(ReactUtils.deepEquals(foo1, foo2, compareIgnoreFirst)) 82 | assert.isFalse(ReactUtils.deepEquals(foo1, foo3, compareIgnoreFirst)) 83 | assert.isTrue(ReactUtils.deepEquals(foo2, foo2, compareIgnoreFirst)) 84 | }) 85 | 86 | }) 87 | -------------------------------------------------------------------------------- /src/core/__tests__/htmlwriter-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import writeHtml from '../htmlwriter' 3 | import setupdom from '../../tests/setupdom' 4 | 5 | setupdom() 6 | 7 | describe('html writer', () => { 8 | it('outputs zero-length string with empty chunks input', () => { 9 | assert.equal(writeHtml(null), '') 10 | assert.equal(writeHtml([]), '') 11 | }) 12 | 13 | it('outputs spans for text input with no newlines', () => { 14 | let chunks = [ 15 | {text: 'Some text.', attrs: {}} 16 | ] 17 | assert.equal(writeHtml(chunks), 'Some text.') 18 | 19 | chunks = [ 20 | {text: 'Some', attrs: {}}, 21 | {text: 'text.', attrs: {}} 22 | ] 23 | assert.equal(writeHtml(chunks), 'Sometext.') 24 | }) 25 | 26 | it('adds appropriate such that multiple spaces do not collapse', () => { 27 | let chunks = [ 28 | {text: 'Some text', attrs: {}} 29 | ] 30 | assert.equal(writeHtml(chunks), 'Some text') 31 | }) 32 | 33 | it('escapes html <, >, and & characters', () => { 34 | let chunks = [ 35 | {text: 'Text with

and & chars.', attrs: {}} 36 | ] 37 | assert.equal(writeHtml(chunks), 'Text with <p> and & chars.') 38 | }) 39 | 40 | it('outputs styled text correctly', () => { 41 | let chunks = [ 42 | {'text': 'bold', 'attrs': {bold: true}}, 43 | {'text': ' italic', 'attrs': {italic: true}}, 44 | {'text': ' underline', 'attrs': {underline: true}}, 45 | {'text': ' superscript', 'attrs': {'superscript': true}}, 46 | {'text': ' subscript', 'attrs': {'subscript': true}}, 47 | {'text': ' strikethrough', 'attrs': {'strikethrough': true}}, 48 | {'text': '.', 'attrs': {}} 49 | ] 50 | assert.equal(writeHtml(chunks), 'bold italic underline superscript subscript strikethrough.') 51 | }) 52 | 53 | it('outputs a single space as a normal space', () => { 54 | let chunks = [ 55 | {'text': ' ', 'attrs': {}} 56 | ] 57 | assert.equal(writeHtml(chunks), ' ') 58 | }) 59 | 60 | it('outputs a non-breaking space with a non-breaking space entity', () => { 61 | let chunks = [ 62 | {'text': '\xA0', 'attrs': {}} 63 | ] 64 | assert.equal(writeHtml(chunks), ' ') 65 | }) 66 | 67 | it('outputs complex styled text correctly', () => { 68 | let chunks = [ 69 | {'text': 'Text with', 'attrs': {}}, 70 | {'text': ' ', 'attrs': {}}, 71 | {'text': 'superscript mixed with', 'attrs': {'superscript': true}}, 72 | {'text': ' ', 'attrs': {'superscript': true}}, 73 | {'text': 'bold', 'attrs': {'bold': true, 'superscript': true}}, 74 | {'text': ' ', 'attrs': {'superscript': true}}, 75 | {'text': 'and', 'attrs': {'superscript': true}}, 76 | {'text': ' ', 'attrs': {'superscript': true}}, 77 | {'text': 'italic', 'attrs': {'italic': true, 'superscript': true}}, 78 | {'text': ' ', 'attrs': {'superscript': true}}, 79 | {'text': 'and', 'attrs': {'superscript': true}}, 80 | {'text': ' ', 'attrs': {'superscript': true}}, 81 | {'text': 'underline', 'attrs': {'superscript': true, 'underline': true}}, 82 | {'text': '.', 'attrs': {}} 83 | ] 84 | assert.equal(writeHtml(chunks), 'Text with superscript mixed with bold and italic and underline.') 85 | }) 86 | 87 | it('outputs styled paragraphs when there is only one chunk between the newlines', () => { 88 | let chunks = [ 89 | {text: 'Line 1.', attrs: {bold: true}}, 90 | {text: '\n', attrs: {}}, 91 | {text: '\n', attrs: {}}, 92 | {text: 'Line 2.', attrs: {italic: true}} 93 | ] 94 | assert.equal(writeHtml(chunks), '

Line 1.

Line 2.

') 95 | }) 96 | 97 | it('outputs styled spans inside paragraphs when there are multiple chunks between the newlines', () => { 98 | let chunks = [ 99 | {text: 'Line 1 Part 1.', attrs: {bold: true}}, 100 | {text: 'Line 1 Part 2.', attrs: {bold: true}}, 101 | {text: '\n', attrs: {}}, 102 | {text: '\n', attrs: {}}, 103 | {text: 'Line 2.', attrs: {italic: true}} 104 | ] 105 | assert.equal(writeHtml(chunks), '

Line 1 Part 1.Line 1 Part 2.

Line 2.

') 106 | }) 107 | 108 | it('outputs breaks when there is one newline', () => { 109 | let chunks = [ 110 | {text: 'Line 1.', attrs: {}}, 111 | {text: '\n', attrs: {}}, 112 | {text: 'Line 2.', attrs: {}} 113 | ] 114 | assert.equal(writeHtml(chunks), 'Line 1.
Line 2.') 115 | }) 116 | 117 | it('outputs a paragraph and a break when there are three newlines', () => { 118 | let chunks = [ 119 | {text: 'Line 1.', attrs: {}}, 120 | {text: '\n', attrs: {}}, 121 | {text: '\n', attrs: {}}, 122 | {text: '\n', attrs: {}}, 123 | {text: 'Line 2.', attrs: {}} 124 | ] 125 | assert.equal(writeHtml(chunks), '

Line 1.


Line 2.

') 126 | }) 127 | 128 | it('outputs paragraphs when there are two newlines', () => { 129 | let chunks = [ 130 | {text: 'Line 1.', attrs: {}}, 131 | {text: '\n', attrs: {}}, 132 | {text: '\n', attrs: {}}, 133 | {text: 'Line 2.', attrs: {}} 134 | ] 135 | assert.equal(writeHtml(chunks), '

Line 1.

Line 2.

') 136 | }) 137 | 138 | it('outputs styled paragraphs when there are two newlines and spans/breaks with one', () => { 139 | let chunks = [ 140 | {text: 'Line 1.', attrs: {}}, 141 | {text: '\n', attrs: {}}, 142 | {text: '\n', attrs: {}}, 143 | {text: 'Line 2.', attrs: {}}, 144 | {text: '\n', attrs: {}}, 145 | {text: 'Line 3.', attrs: {}} 146 | ] 147 | assert.equal(writeHtml(chunks), '

Line 1.

Line 2.

Line 3.

') 148 | }) 149 | 150 | it('outputs breaks when there are two newlines at the end', () => { 151 | let chunks = [ 152 | {text: 'Line 1.', attrs: {}}, 153 | {text: '\n', attrs: {}}, 154 | {text: '\n', attrs: {}} 155 | ] 156 | assert.equal(writeHtml(chunks), '

Line 1.



') 157 | }) 158 | 159 | it('outputs breaks when there are more than two newlines at the end', () => { 160 | let chunks = [ 161 | {text: 'Line 1.', attrs: {}}, 162 | {text: '\n', attrs: {}}, 163 | {text: '\n', attrs: {}}, 164 | {text: '\n', attrs: {}} 165 | ] 166 | assert.equal(writeHtml(chunks), '

Line 1.




') 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /src/core/__tests__/textwriter-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import writeText from '../textwriter' 3 | 4 | describe('text writer', () => { 5 | it('outputs zero-length string with empty chunks input', () => { 6 | assert.equal(writeText(null), '') 7 | assert.equal(writeText([]), '') 8 | }) 9 | 10 | it('outputs spans for text input with no newlines', () => { 11 | let chunks = [ 12 | {text: 'Some text.', attrs: {}} 13 | ] 14 | assert.equal(writeText(chunks), 'Some text.') 15 | 16 | chunks = [ 17 | {text: 'Some', attrs: {}}, 18 | {text: 'text.', attrs: {}} 19 | ] 20 | assert.equal(writeText(chunks), 'Sometext.') 21 | }) 22 | 23 | it('does not collapse spaces', () => { 24 | let chunks = [ 25 | {text: 'Some text', attrs: {}} 26 | ] 27 | assert.equal(writeText(chunks), 'Some text') 28 | }) 29 | 30 | it('does not escape html <, >, and & characters', () => { 31 | let chunks = [ 32 | {text: 'Text with

and & chars.', attrs: {}} 33 | ] 34 | assert.equal(writeText(chunks), 'Text with

and & chars.') 35 | }) 36 | 37 | it('ignores text styles', () => { 38 | let chunks = [ 39 | {'text': 'bold', 'attrs': {bold: true}}, 40 | {'text': ' italic', 'attrs': {italic: true}}, 41 | {'text': ' underline', 'attrs': {underline: true}}, 42 | {'text': ' superscript', 'attrs': {'superscript': true}}, 43 | {'text': ' subscript', 'attrs': {'subscript': true}}, 44 | {'text': ' strikethrough', 'attrs': {'strikethrough': true}}, 45 | {'text': '.', 'attrs': {}} 46 | ] 47 | assert.equal(writeText(chunks), 'bold italic underline superscript subscript strikethrough.') 48 | }) 49 | 50 | it('outputs a single space as a normal space', () => { 51 | let chunks = [ 52 | {'text': ' ', 'attrs': {}} 53 | ] 54 | assert.equal(writeText(chunks), ' ') 55 | }) 56 | 57 | it('outputs a non-breaking space as a non-breaking space', () => { 58 | let chunks = [ 59 | {'text': '\xA0', 'attrs': {}} 60 | ] 61 | assert.equal(writeText(chunks), '\xA0') 62 | }) 63 | 64 | it('outputs newlines as a newline', () => { 65 | let chunks = [ 66 | {text: 'Line 1.', attrs: {bold: true}}, 67 | {text: '\n', attrs: {}}, 68 | {text: 'Line 2.', attrs: {italic: true}} 69 | ] 70 | assert.equal(writeText(chunks), 'Line 1.\nLine 2.') 71 | }) 72 | 73 | it('outputs paragraph breaks as two newlines', () => { 74 | let chunks = [ 75 | {text: 'Line 1.', attrs: {bold: true}}, 76 | {text: '\n', attrs: {}}, 77 | {text: '\n', attrs: {}}, 78 | {text: 'Line 2.', attrs: {italic: true}} 79 | ] 80 | assert.equal(writeText(chunks), 'Line 1.\n\nLine 2.') 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/core/__tests__/tokenizer-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import _ from 'lodash' 3 | import tokenizer from '../tokenizer' 4 | 5 | const rangeSub_ = function(text, ranges) { 6 | return _.partial((t, r, index) => { 7 | let range = r[index] 8 | return t.substring(range.start, range.end) 9 | }, text, ranges) 10 | } 11 | 12 | describe('word tokenizer', () => { 13 | it('tokenizes words with trailing spaces', () => { 14 | let text = 'A few words, hy-phen-ated too.' 15 | let ranges = tokenizer(text) 16 | let sub = rangeSub_(text, ranges) 17 | 18 | assert.equal(ranges.length, 7) 19 | assert.equal(sub(0), 'A ') 20 | assert.equal(sub(1), 'few ') 21 | assert.equal(sub(2), 'words') 22 | assert.equal(sub(3), ', ') 23 | assert.equal(sub(4), 'hy-phen-ated ') 24 | assert.equal(sub(5), 'too') 25 | assert.equal(sub(6), '.') 26 | 27 | assert.equal(true, ranges[0].isWord) 28 | assert.equal(true, ranges[1].isWord) 29 | assert.equal(true, ranges[2].isWord) 30 | assert.equal(false, ranges[3].isWord) 31 | assert.equal(true, ranges[4].isWord) 32 | assert.equal(true, ranges[5].isWord) 33 | assert.equal(false, ranges[6].isWord) 34 | }) 35 | 36 | it('tokenizes words without trailing spaces', () => { 37 | let text = 'A few words, hy-phen-ated too.' 38 | let ranges = tokenizer(text, { includeTrailingSpace: false }) 39 | let sub = rangeSub_(text, ranges) 40 | 41 | assert.equal(ranges.length, 10) 42 | assert.equal(sub(0), 'A') 43 | assert.equal(sub(1), ' ') 44 | assert.equal(sub(2), 'few') 45 | assert.equal(sub(3), ' ') 46 | assert.equal(sub(4), 'words') 47 | assert.equal(sub(5), ', ') 48 | assert.equal(sub(6), 'hy-phen-ated') 49 | assert.equal(sub(7), ' ') 50 | assert.equal(sub(8), 'too') 51 | assert.equal(sub(9), '.') 52 | 53 | assert.equal(true, ranges[0].isWord) 54 | assert.equal(false, ranges[1].isWord) 55 | assert.equal(true, ranges[2].isWord) 56 | assert.equal(false, ranges[3].isWord) 57 | assert.equal(true, ranges[4].isWord) 58 | assert.equal(false, ranges[5].isWord) 59 | assert.equal(true, ranges[6].isWord) 60 | assert.equal(false, ranges[7].isWord) 61 | assert.equal(true, ranges[8].isWord) 62 | assert.equal(false, ranges[9].isWord) 63 | }) 64 | 65 | it('tokenizes words with leading spaces', () => { 66 | let text = 'A few words, hy-phen-ated too.' 67 | let ranges = tokenizer(text, { includeLeadingSpace: true }) 68 | let sub = rangeSub_(text, ranges) 69 | 70 | assert.equal(ranges.length, 7) 71 | assert.equal(sub(0), 'A') 72 | assert.equal(sub(1), ' few') 73 | assert.equal(sub(2), ' words') 74 | assert.equal(sub(3), ',') 75 | assert.equal(sub(4), ' hy-phen-ated') 76 | assert.equal(sub(5), ' too') 77 | assert.equal(sub(6), '.') 78 | 79 | assert.equal(true, ranges[0].isWord) 80 | assert.equal(true, ranges[1].isWord) 81 | assert.equal(true, ranges[2].isWord) 82 | assert.equal(false, ranges[3].isWord) 83 | assert.equal(true, ranges[4].isWord) 84 | assert.equal(true, ranges[5].isWord) 85 | assert.equal(false, ranges[6].isWord) 86 | }) 87 | 88 | it('tokenizes words with both leading and trailing spaces', () => { 89 | let text = 'A few words, hy-phen-ated too.' 90 | let ranges = tokenizer(text, { includeLeadingSpace: true, includeTrailingSpace: true }) 91 | let sub = rangeSub_(text, ranges) 92 | 93 | //assert.equal(ranges.length, 7) 94 | assert.equal(sub(0), 'A ') 95 | assert.equal(sub(1), ' few ') 96 | assert.equal(sub(2), ' words') 97 | assert.equal(sub(3), ',') 98 | assert.equal(sub(4), ' hy-phen-ated ') 99 | assert.equal(sub(5), ' too') 100 | assert.equal(sub(6), '.') 101 | 102 | assert.equal(true, ranges[0].isWord) 103 | assert.equal(true, ranges[1].isWord) 104 | assert.equal(true, ranges[2].isWord) 105 | assert.equal(false, ranges[3].isWord) 106 | assert.equal(true, ranges[4].isWord) 107 | assert.equal(true, ranges[5].isWord) 108 | assert.equal(false, ranges[6].isWord) 109 | }) 110 | 111 | it('tokenizes newlines as words', () => { 112 | let text = 'A few \nwords,\nwith newlines.\n' 113 | let ranges = tokenizer(text) 114 | let sub = rangeSub_(text, ranges) 115 | 116 | //assert.equal(ranges.length, 7) 117 | assert.equal(sub(0), 'A ') 118 | assert.equal(sub(1), 'few ') 119 | assert.equal(sub(2), '\n') 120 | assert.equal(sub(3), 'words') 121 | assert.equal(sub(4), ',') 122 | assert.equal(sub(5), '\n') 123 | assert.equal(sub(6), 'with ') 124 | assert.equal(sub(7), 'newlines') 125 | assert.equal(sub(8), '.') 126 | assert.equal(sub(9), '\n') 127 | 128 | assert.equal(true, ranges[0].isWord) 129 | assert.equal(true, ranges[1].isWord) 130 | assert.equal(true, ranges[2].isWord) 131 | assert.equal(true, ranges[3].isWord) 132 | assert.equal(false, ranges[4].isWord) 133 | assert.equal(true, ranges[5].isWord) 134 | assert.equal(true, ranges[6].isWord) 135 | assert.equal(true, ranges[7].isWord) 136 | assert.equal(false, ranges[8].isWord) 137 | assert.equal(true, ranges[9].isWord) 138 | }) 139 | 140 | }) 141 | -------------------------------------------------------------------------------- /src/core/alt.js: -------------------------------------------------------------------------------- 1 | // weird, this should work but doesn't 2 | //import Alt from 'alt' 3 | import Alt from 'alt/lib/index' 4 | 5 | let alt = new Alt() 6 | 7 | //if(__DEV__) { 8 | // let chromeDebug = require('alt/utils/chromeDebug') 9 | // chromeDebug(alt) 10 | //} 11 | 12 | export default alt 13 | -------------------------------------------------------------------------------- /src/core/attributes.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export const ATTR = { 4 | BOLD: 'bold', 5 | ITALIC: 'italic', 6 | UNDERLINE: 'underline', 7 | STRIKETHROUGH: 'strikethrough', 8 | SUPERSCRIPT: 'superscript', 9 | SUBSCRIPT: 'subscript' 10 | } 11 | 12 | function hasAttribute(attributes, attr) { 13 | return attributes && attributes[attr] 14 | } 15 | 16 | export function hasAttributeFor(attributes) { 17 | return _.partial(hasAttribute, attributes) 18 | } 19 | 20 | export function attributesEqual(attr1, attr2) { 21 | let normalize = a => _.pick(a, value => value) // pick entries where value exists and is truthy 22 | return _.isEqual(normalize(attr1), normalize(attr2)) 23 | } 24 | -------------------------------------------------------------------------------- /src/core/dom.js: -------------------------------------------------------------------------------- 1 | export function getNumericStyleProperty(style, prop) { 2 | return parseInt(style.getPropertyValue(prop), 10) 3 | } 4 | 5 | export function getPixelStyleProperty(style, prop) { 6 | return Number(style.getPropertyValue(prop).match(/(\d*(\.\d*)?)px/)[1]) 7 | } 8 | 9 | /** 10 | * The computed font-weight property is textual ("bold") on some browsers e.g. Chrome and numeric Strings ("700") 11 | * on some other browsers e.g. Firefox. This method normalizes the font-weight value to a textual property. 12 | * See https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight. 13 | * 14 | * This method does not do a complete normalization e.g. lighter, bolder, etc. Only basic normal and bold weights 15 | * are currently handled. 16 | * @param fontWeight 17 | */ 18 | export function normalizeFontWeight(fontWeight) { 19 | let fontWeightNumeric = parseInt(fontWeight, 10) 20 | if (Number.isNaN(fontWeightNumeric)) { 21 | return fontWeight 22 | } else if (fontWeightNumeric === 400) { 23 | return 'normal' 24 | } else if (fontWeightNumeric === 700) { 25 | return 'bold' 26 | } else { 27 | return fontWeight 28 | } 29 | } 30 | 31 | /** 32 | * Returns the currently set browser minimum font size. We create an invisible element and set its font-size 33 | * style to 1px. We then obtain the browser's computed font-size property, which should be the minimum size 34 | * allowed by the browser's current settings. TODO is there a better way to get the browser minimum font size? 35 | */ 36 | export function detectMinFontSize() { 37 | let elem = document.createElement('div') 38 | elem.style['font-size'] = '1px' 39 | elem.style.display = 'none' 40 | elem.style.visibility = 'hidden' 41 | document.body.appendChild(elem) 42 | let style = getComputedStyle(elem, null) 43 | let size = getPixelStyleProperty(style, 'font-size') 44 | document.body.removeChild(elem) 45 | return size 46 | } 47 | 48 | /** 49 | * Returns the position of an element relative to the page, or until a parent element for which the provided 50 | * 'until' function is truthy. Basic implementation from http://stackoverflow.com/a/5776220/430128. 51 | * @param elem 52 | * @param until A function, if provided, is passed each parent element and computed style. If it returns a 53 | * truthy value, the returned position will be relative to that element. 54 | * @returns {{x: number, y: number}} 55 | */ 56 | export function elementPosition(elem, until) { 57 | let x = 0 58 | let y = 0 59 | let inner = true 60 | 61 | while (elem) { 62 | let style = getComputedStyle(elem, null) 63 | if(until && until(elem, style)) break 64 | x += elem.offsetLeft 65 | y += elem.offsetTop 66 | y += getNumericStyleProperty(style, 'border-top-width') 67 | x += getNumericStyleProperty(style, 'border-left-width') 68 | if (inner) { 69 | y += getNumericStyleProperty(style, 'padding-top') 70 | x += getNumericStyleProperty(style, 'padding-left') 71 | } 72 | inner = false 73 | elem = elem.offsetParent 74 | } 75 | return {x: x, y: y} 76 | } 77 | 78 | /** 79 | * Returns the number of pixels to scroll the window by (in the x and y directions) to make an element completely 80 | * visible within the viewport. 81 | * @param el The element 82 | * @param xGutter 83 | * @param yGutter 84 | * @return {{xDelta: number, yDelta: number}} 85 | */ 86 | export function scrollByToVisible(el, xGutter, yGutter) { 87 | let rect = el.getBoundingClientRect() 88 | let xDelta = 0 89 | let yDelta = 0 90 | xGutter = xGutter || 0 91 | yGutter = yGutter || 0 92 | 93 | let windowWidth = document.documentElement.clientWidth || document.body.clientWidth 94 | if(rect.right < xGutter) xDelta = rect.right - xGutter 95 | else if(rect.left > windowWidth - xGutter) xDelta = rect.left - windowWidth + xGutter 96 | 97 | let windowHeight = document.documentElement.clientHeight || document.body.clientHeight 98 | if(rect.top < yGutter) yDelta = rect.top - yGutter 99 | else if(rect.bottom > windowHeight - yGutter) yDelta = rect.bottom - windowHeight + yGutter 100 | 101 | return { 102 | xDelta: xDelta, 103 | yDelta: yDelta 104 | } 105 | } 106 | 107 | /** 108 | * Empties a DOM node of all its children. 109 | * @param {Node} node 110 | */ 111 | export function emptyNode(node) { 112 | while (node.firstChild) { 113 | node.removeChild(node.firstChild) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/core/htmlwriter.js: -------------------------------------------------------------------------------- 1 | import {ATTR, hasAttributeFor} from './attributes' 2 | import classNames from 'classnames' 3 | import CSSPropertyOperations from 'react/lib/CSSPropertyOperations' 4 | 5 | function styleForAttributes(attributes) { 6 | let hasAttribute = hasAttributeFor(attributes) 7 | 8 | let style = {} 9 | let superscript = hasAttribute(ATTR.SUPERSCRIPT) 10 | let subscript = hasAttribute(ATTR.SUBSCRIPT) 11 | if(superscript || subscript) { 12 | style.verticalAlign = classNames({ 13 | super: superscript, 14 | sub: subscript 15 | }) 16 | } 17 | 18 | // font size, weight, style 19 | //let fontSize = this.fontSizeFromAttributes(this.props.fontSize, attributes) 20 | 21 | if(hasAttribute(ATTR.BOLD)) { 22 | style.fontWeight = 'bold' 23 | } 24 | if(hasAttribute(ATTR.ITALIC)) { 25 | style.fontStyle = 'italic' 26 | } 27 | 28 | // text-decoration 29 | let underline = hasAttribute(ATTR.UNDERLINE) 30 | let strikethrough = hasAttribute(ATTR.STRIKETHROUGH) 31 | 32 | if(underline || strikethrough) { 33 | style.textDecoration = classNames({ 34 | underline: underline, 35 | 'line-through': strikethrough 36 | }) 37 | } 38 | 39 | return style 40 | } 41 | 42 | function setStyle(el, style, preserveWhitespace) { 43 | if (preserveWhitespace) { 44 | style.whiteSpace = 'pre-wrap' 45 | } 46 | let cssString = CSSPropertyOperations.createMarkupForStyles(style) 47 | el.setAttribute('style', cssString) 48 | } 49 | 50 | /** 51 | * Writes chunks of rich text into an HTML document. 52 | * 53 | * @param {Array} chunks The rich text chunks to write. 54 | */ 55 | export function writeHtmlAsDom(chunks) { 56 | let html = document.createDocumentFragment() 57 | 58 | if(!chunks) { 59 | chunks = [] 60 | } 61 | 62 | let pendingChunks = [] 63 | 64 | let pushToHtml = fragment => { 65 | html.appendChild(fragment) 66 | } 67 | 68 | let createSpan = chunk => { 69 | let textNode = document.createTextNode(chunk.text) 70 | let span = document.createElement('SPAN') 71 | span.appendChild(textNode) 72 | setStyle(span, styleForAttributes(chunk.attrs), true) 73 | return span 74 | } 75 | 76 | let pushBreakToHtml = () => { 77 | pushToHtml(document.createElement('BR')) 78 | } 79 | 80 | let pushSpansToHtml = () => { 81 | if(pendingChunks.length === 0) { 82 | return 83 | } 84 | let spans = pendingChunks.map(createSpan) 85 | let fragment = document.createDocumentFragment() 86 | spans.forEach(s => fragment.appendChild(s)) 87 | pushToHtml(fragment) 88 | pendingChunks = [] 89 | } 90 | 91 | let pushParaToHtml = () => { 92 | if(pendingChunks.length === 0) { 93 | return 94 | } 95 | let para = document.createElement('P') 96 | if(pendingChunks.length > 1) { 97 | // encapsulate chunks in styled spans 98 | let spans = pendingChunks.map(createSpan) 99 | spans.forEach(s => para.appendChild(s)) 100 | } else if(pendingChunks.length === 1) { 101 | // encapsulate chunk in styled para 102 | let textNode = document.createTextNode(pendingChunks[0].text) 103 | para.appendChild(textNode) 104 | setStyle(para, styleForAttributes(pendingChunks[0].attrs), true) 105 | } 106 | pushToHtml(para) 107 | pendingChunks = [] 108 | } 109 | 110 | let newlineCount = 0 111 | let paraClean = true 112 | 113 | let handleNewlines = (atEnd) => { 114 | if(newlineCount > 0) { 115 | if(newlineCount >= 2) { 116 | // a bunch of newlines in a row, we need to push a paragraph for the first two and assume the rest are breaks 117 | // see http://www.w3.org/TR/html5/dom.html#palpable-content 118 | pushParaToHtml(pendingChunks) 119 | paraClean = false 120 | if(atEnd) { 121 | // add line breaks too 122 | while(newlineCount > 0) { 123 | pushBreakToHtml() 124 | newlineCount-- 125 | } 126 | } else { 127 | newlineCount -= 2 128 | } 129 | } 130 | // treat any more newlines as line breaks 131 | while(newlineCount > 0) { 132 | pushSpansToHtml(pendingChunks) 133 | pushBreakToHtml() 134 | newlineCount-- 135 | } 136 | } 137 | } 138 | 139 | chunks.forEach(c => { 140 | if(c.text === '\n') { 141 | newlineCount++ 142 | } else { 143 | handleNewlines(false) 144 | pendingChunks.push(c) 145 | } 146 | }) 147 | 148 | // trailing newlines 149 | handleNewlines(true) 150 | 151 | if(paraClean) { 152 | pushSpansToHtml(pendingChunks) 153 | } else { 154 | pushParaToHtml(pendingChunks) 155 | } 156 | 157 | return html 158 | } 159 | 160 | /** 161 | * Writes chunks of rich text into an HTML document. 162 | * 163 | * @param {Array} chunks The rich text chunks to write. 164 | */ 165 | export default function writeHtml(chunks) { 166 | let html = writeHtmlAsDom(chunks) 167 | 168 | // fragments don't have an innerHTML method so we need to wrap it in another container first 169 | let container = document.createElement('div') 170 | container.appendChild(html) 171 | return container.innerHTML 172 | } 173 | -------------------------------------------------------------------------------- /src/core/replica.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a Swarm spec and returns its data as an object. 3 | * @param spec 4 | * @returns {{source: *, op: *}} 5 | */ 6 | export function parseSpec(spec) { 7 | // spec seems to have some internal parsing state "index" which prevents accessing it consistently 8 | // https://github.com/gritzko/swarm/issues/53 9 | let oldIndex = spec.index 10 | spec.index = 0 11 | let source 12 | try { 13 | source = spec.source() 14 | } catch (e) { 15 | source = null 16 | } 17 | let op 18 | try { 19 | op = spec.op() 20 | } catch (e) { 21 | op = null 22 | } 23 | spec.index = oldIndex 24 | return { 25 | source: source, 26 | op: op 27 | } 28 | } 29 | 30 | /** 31 | * Returns the source from within a Swarm spec. 32 | * @param spec 33 | * @returns {*} 34 | */ 35 | export function sourceOf(spec) { 36 | return parseSpec(spec).source 37 | } 38 | -------------------------------------------------------------------------------- /src/core/swarmclient.js: -------------------------------------------------------------------------------- 1 | import SwarmBase from 'swarm' 2 | import swarmFactory from './swarmfactory' 3 | 4 | export default class SwarmClient { 5 | constructor(localUser, config) { 6 | let Swarm = swarmFactory(SwarmBase) 7 | this.Swarm = Swarm 8 | 9 | this.id = localUser 10 | 11 | // server host uri (/websocket is appended because https://github.com/websockets/ws/issues/131) 12 | let windowLocation = window.location.hostname 13 | let port 14 | if(config && config.wsPort) { 15 | port = config.wsPort 16 | } else { 17 | port = window.location.port 18 | } 19 | windowLocation = windowLocation + ':' + port 20 | this.wsServerUri = 'ws://' + windowLocation + '/websocket' 21 | 22 | let hash = window.location.hash || '#0' 23 | 24 | // create Host 25 | this.host = Swarm.env.localhost = new Swarm.Host(this.id + hash.replace('#', '~')) 26 | 27 | // connect to server 28 | this.pipe = this.host.connect(this.wsServerUri, {delay: -1}) 29 | 30 | this.reonHooks = [] 31 | this.unloadHooks = [] 32 | 33 | //catch online/offline status changes 34 | this.host.on('reon', (spec, val) => { // eslint-disable-line no-unused-vars 35 | document.body.setAttribute('connected', this.host.isUplinked()) 36 | for(let i = 0; i < this.reonHooks.length; i++) { 37 | try { 38 | this.reonHooks[i]() 39 | } catch (e) { 40 | console.warn('Swarm reon hook failed.', e) 41 | } 42 | } 43 | }) 44 | this.host.on('reoff', (spec, val) => { // eslint-disable-line no-unused-vars 45 | document.body.setAttribute('connected', this.host.isUplinked()) 46 | }) 47 | this.host.on('off', (spec, val) => { // eslint-disable-line no-unused-vars 48 | document.body.setAttribute('connected', this.host.isUplinked()) 49 | }) 50 | 51 | let unloaded = false 52 | let unload = () => { 53 | if(unloaded) { 54 | return 55 | } 56 | unloaded = true 57 | // bug, Swarm.js does not close the Websocket because Pipe.close() expects stream.close() to exist, get a ref and do it manually 58 | let ws = this.pipe && this.pipe.stream && this.pipe.stream.ws ? this.pipe.stream.ws : null 59 | for(let i = 0; i < this.unloadHooks.length; i++) { 60 | try { 61 | this.unloadHooks[i]() 62 | } catch (e) { 63 | console.warn('Swarm unload hook failed.', e) 64 | } 65 | } 66 | this.host.close(() => { 67 | // bug, Swarm.js does not close the Websocket because Pipe.close() expects stream.close() to exist 68 | this.pipe.close() 69 | if(ws) { 70 | ws.close() 71 | } 72 | }) 73 | } 74 | 75 | // hopefully one of these events works, doesn't seem to be consistent 76 | window.addEventListener('beforeunload', function() { 77 | unload() 78 | }) 79 | window.addEventListener('pagehide', () => { 80 | unload() 81 | }) 82 | window.addEventListener('unload', () => { 83 | unload() 84 | }) 85 | } 86 | 87 | addReonHook(f) { 88 | this.reonHooks.push(f) 89 | } 90 | 91 | addUnloadHook(f) { 92 | this.unloadHooks.push(f) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/core/swarmfactory.js: -------------------------------------------------------------------------------- 1 | import Text from './RichText' 2 | import CursorModel from './CursorModel' 3 | import CursorSet from './CursorSet' 4 | 5 | let SwarmFactory = function SwarmFactory(Swarm) { 6 | Swarm.debug = false 7 | 8 | Swarm.Text = Text 9 | Swarm.CursorModel = CursorModel 10 | Swarm.CursorSet = CursorSet 11 | 12 | let env = Swarm.env 13 | env.debug = false 14 | env.log = (spec, value, source, host) => { // no-unused-vars 15 | //console.log('spec=', spec, 'value=', value, 'source=', source, 'host=', host) 16 | } 17 | 18 | return Swarm 19 | } 20 | 21 | export default SwarmFactory 22 | -------------------------------------------------------------------------------- /src/core/swarmserver.js: -------------------------------------------------------------------------------- 1 | import SwarmBase from 'swarm/lib/NodeServer' 2 | import swarmFactory from './swarmfactory' 3 | import Spec from 'swarm/lib/Spec' 4 | import redis from 'redis' 5 | import RedisStorage from '../vendor/swarm/RedisStorage' 6 | 7 | export default class SwarmServer { 8 | constructor(redisConfig) { 9 | let Swarm = swarmFactory(SwarmBase) 10 | this.Swarm = Swarm 11 | 12 | //let storage = new Swarm.FileStorage('.swarm') 13 | let storage = new RedisStorage({ 14 | redis: redis, 15 | redisConnectParams: redisConfig, 16 | debug: false 17 | }) 18 | storage.open() 19 | 20 | Swarm.host = new Swarm.Host('swarm~nodejs', 0, storage) 21 | Swarm.env.localhost = Swarm.host 22 | 23 | setInterval(() => { 24 | this.cleanCursorSets() 25 | }, 5000) 26 | } 27 | 28 | cleanCursorSet(cursorSet) { 29 | let online = {} 30 | let Swarm = this.Swarm 31 | for (let src in Swarm.host.sources) { 32 | if(!Swarm.host.sources.hasOwnProperty(src)) continue 33 | let m = src.match(/([A-Za-z0-9_\~]+)(\~[A-Za-z0-9_\~]+)/) 34 | if (!m) { 35 | console.error('Unknown Swarm source', src) 36 | continue 37 | } 38 | online[m[1]] = true 39 | } 40 | 41 | if (!cursorSet._version) { 42 | return 43 | } 44 | 45 | let cursorSetMoribund = {} 46 | let cursors = cursorSet.list() 47 | //console.log('cursors', cursors.reduce((arr, c) => { arr.push({_id: c._id, name: c.name, state: c.state, ms: c.ms}); return arr }, [])) 48 | for (let s of cursors) { 49 | let spec = s.spec() 50 | if (spec.type() !== 'Cursor') { 51 | continue 52 | } 53 | let id = spec.id() 54 | cursorSetMoribund[id] = s.ms 55 | } 56 | //console.log('cursorSet:', cursorSet._id, 'cursors:', cursorSetMoribund) 57 | // cursors live for 10 minutes after last use and then disappear (recreated by client if user resumes editing) 58 | let ancient = Date.now() - 10 * 60 * 1000 59 | for (let id in cursorSetMoribund) { 60 | if(!cursorSetMoribund.hasOwnProperty(id)) continue 61 | let ts = cursorSetMoribund[id] 62 | if (ts < ancient) { 63 | cursorSet.removeObject('/Cursor#' + id) 64 | delete cursorSetMoribund[id] 65 | } 66 | } 67 | } 68 | 69 | cleanCursorSets() { 70 | let Swarm = this.Swarm 71 | Object.keys(Swarm.host.objects).map(s => new Spec(s)).filter(s => s.type() === 'CursorSet').forEach(s => { 72 | this.cleanCursorSet(Swarm.host.get(s)) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/core/textwriter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Writes chunks of rich text into a plain text. The implementation is simple: just strip any style 3 | * information from the rich text. 4 | * 5 | * @param {Array} chunks The rich text chunks to write. 6 | */ 7 | export default function writeText(chunks) { 8 | let text = '' 9 | 10 | if(!chunks) { 11 | chunks = [] 12 | } 13 | 14 | chunks.forEach(c => { 15 | text += c.text 16 | }) 17 | 18 | return text 19 | } 20 | -------------------------------------------------------------------------------- /src/core/tokenizer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on https://github.com/timdown/rangy/blob/master/src/modules/rangy-textrange.js#L119 3 | */ 4 | 5 | import _ from 'lodash' 6 | 7 | const DEFAULT_WORD_OPTIONS = { 8 | en: { 9 | wordRegex: /([a-z0-9_-]+('[a-z0-9_-]+)*|\n)/gi, 10 | includeLeadingSpace: false, 11 | includeTrailingSpace: true 12 | } 13 | } 14 | 15 | // does not include line breaks 16 | const WHITESPACE_REGEX = /^[\t \u00A0\u1680\u180E\u2000-\u200B\u202F\u205F\u3000]+$/ 17 | 18 | export function isWhitespace(char) { 19 | return WHITESPACE_REGEX.test(char) 20 | } 21 | 22 | export default function tokenizer(chars, wordOptions) { 23 | let word = _.isArray(chars) ? chars.join('') : chars 24 | let result 25 | let tokenRanges = [] 26 | // by default if our options include leading spaces but trailing is not specified, turn off trailing 27 | if(wordOptions && wordOptions.includeLeadingSpace && !wordOptions.includeTrailingSpace) { 28 | wordOptions.includeTrailingSpace = false 29 | } 30 | wordOptions = _.merge({}, DEFAULT_WORD_OPTIONS.en, wordOptions) 31 | 32 | let createTokenRange = function(start, end, isWord) { 33 | tokenRanges.push({start: start, end: end, isWord: isWord}) 34 | } 35 | 36 | // Match words and mark characters 37 | let lastWordEnd = 0 38 | let wordStart 39 | let wordEnd 40 | while ((result = wordOptions.wordRegex.exec(word))) { 41 | wordStart = result.index 42 | wordEnd = wordStart + result[0].length 43 | 44 | // Get leading space characters for word 45 | if (wordOptions.includeLeadingSpace) { 46 | while (isWhitespace(chars[wordStart - 1])) { 47 | --wordStart 48 | } 49 | } 50 | 51 | // Create token for non-word characters preceding this word 52 | if (wordStart > lastWordEnd) { 53 | createTokenRange(lastWordEnd, wordStart, false) 54 | } 55 | 56 | // Get trailing space characters for word 57 | if (wordOptions.includeTrailingSpace) { 58 | while (isWhitespace(chars[wordEnd])) { 59 | ++wordEnd 60 | } 61 | } 62 | createTokenRange(wordStart, wordEnd, true) 63 | lastWordEnd = wordEnd 64 | } 65 | 66 | // Create token for trailing non-word characters, if any exist 67 | if (lastWordEnd < chars.length) { 68 | createTokenRange(lastWordEnd, chars.length, false) 69 | } 70 | 71 | return tokenRanges 72 | } 73 | -------------------------------------------------------------------------------- /src/core/utils.js: -------------------------------------------------------------------------------- 1 | import invariant from 'react/lib/invariant' 2 | 3 | // http://stackoverflow.com/a/4156156/430128 4 | export function pushArray(arr, arr2) { 5 | arr.push.apply(arr, arr2) 6 | } 7 | 8 | export function pushSet(set1, set2) { 9 | invariant(set2, 'Set to push into must be defined.') 10 | if(!set1) return 11 | for(let value of set1) { 12 | set2.add(value) 13 | } 14 | } 15 | 16 | export function setIntersection(set1, set2) { 17 | if(!set1 || !set2) return [] 18 | return [...set1].filter(x => set2.has(x)) 19 | } 20 | 21 | export function logInGroup(group, f) { 22 | if(console.group) console.group(group) 23 | try { 24 | f() 25 | } finally { 26 | if(console.groupEnd) console.groupEnd() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/flux/EditorActions.js: -------------------------------------------------------------------------------- 1 | import alt from '../core/alt' 2 | 3 | class EditorActions { 4 | constructor() { 5 | } 6 | 7 | initialize(config, replica) { 8 | this.dispatch({config, replica}) 9 | } 10 | 11 | onCursorModelUpdate(cursorModelUpdate) { 12 | this.dispatch(cursorModelUpdate) 13 | } 14 | 15 | replicaInitialized() { 16 | this.dispatch() 17 | } 18 | 19 | replicaUpdated() { 20 | this.dispatch() 21 | } 22 | 23 | focusInput() { 24 | this.dispatch() 25 | } 26 | 27 | inputFocusLost() { 28 | this.dispatch() 29 | } 30 | 31 | reflow() { 32 | this.dispatch() 33 | } 34 | 35 | setRemoteCursorPosition(remoteCursor) { 36 | this.dispatch(remoteCursor) 37 | } 38 | 39 | unsetRemoteCursorPosition(remoteCursor) { 40 | this.dispatch(remoteCursor) 41 | } 42 | 43 | revealRemoteCursorName(remoteCursor) { 44 | this.dispatch(remoteCursor) 45 | } 46 | 47 | // navigation actions 48 | navigateLeft() { 49 | this.dispatch() 50 | } 51 | 52 | navigateRight() { 53 | this.dispatch() 54 | } 55 | 56 | navigateUp() { 57 | this.dispatch() 58 | } 59 | 60 | navigateDown() { 61 | this.dispatch() 62 | } 63 | 64 | navigatePageUp() { 65 | this.dispatch() 66 | } 67 | 68 | navigatePageDown() { 69 | this.dispatch() 70 | } 71 | 72 | navigateStart() { 73 | this.dispatch() 74 | } 75 | 76 | navigateStartLine() { 77 | this.dispatch() 78 | } 79 | 80 | navigateEnd() { 81 | this.dispatch() 82 | } 83 | 84 | navigateEndLine() { 85 | this.dispatch() 86 | } 87 | 88 | navigateWordLeft() { 89 | this.dispatch() 90 | } 91 | 92 | navigateWordRight() { 93 | this.dispatch() 94 | } 95 | 96 | navigateToCoordinates(coordinates) { 97 | this.dispatch(coordinates) 98 | } 99 | 100 | // selection actions 101 | selectionLeft() { 102 | this.dispatch() 103 | } 104 | 105 | selectionRight() { 106 | this.dispatch() 107 | } 108 | 109 | selectionUp() { 110 | this.dispatch() 111 | } 112 | 113 | selectionDown() { 114 | this.dispatch() 115 | } 116 | 117 | selectionPageUp() { 118 | this.dispatch() 119 | } 120 | 121 | selectionPageDown() { 122 | this.dispatch() 123 | } 124 | 125 | selectionStart() { 126 | this.dispatch() 127 | } 128 | 129 | selectionStartLine() { 130 | this.dispatch() 131 | } 132 | 133 | selectionEnd() { 134 | this.dispatch() 135 | } 136 | 137 | selectionEndLine() { 138 | this.dispatch() 139 | } 140 | 141 | selectionWordLeft() { 142 | this.dispatch() 143 | } 144 | 145 | selectionWordRight() { 146 | this.dispatch() 147 | } 148 | 149 | selectionAll() { 150 | this.dispatch() 151 | } 152 | 153 | selectToCoordinates(coordinates) { 154 | this.dispatch(coordinates) 155 | } 156 | 157 | selectWordAtCurrentPosition() { 158 | this.dispatch() 159 | } 160 | 161 | copySelection(copyHandler) { 162 | this.dispatch(copyHandler) 163 | } 164 | 165 | insertChars(value, attributes, atPosition) { 166 | this.dispatch({value, attributes, atPosition}) 167 | } 168 | 169 | insertCharsBatch(chunks) { 170 | this.dispatch(chunks) 171 | } 172 | 173 | eraseCharBack() { 174 | this.dispatch() 175 | } 176 | 177 | eraseCharForward() { 178 | this.dispatch() 179 | } 180 | 181 | eraseWordBack() { 182 | this.dispatch() 183 | } 184 | 185 | eraseWordForward() { 186 | this.dispatch() 187 | } 188 | 189 | eraseSelection() { 190 | this.dispatch() 191 | } 192 | 193 | // toggle attribute actions 194 | toggleBold() { 195 | this.dispatch() 196 | } 197 | 198 | toggleItalics() { 199 | this.dispatch() 200 | } 201 | 202 | toggleUnderline() { 203 | this.dispatch() 204 | } 205 | 206 | toggleStrikethrough() { 207 | this.dispatch() 208 | } 209 | 210 | toggleSuperscript() { 211 | this.dispatch() 212 | } 213 | 214 | toggleSubscript() { 215 | this.dispatch() 216 | } 217 | 218 | setActiveAttributes() { 219 | this.dispatch() 220 | } 221 | 222 | registerEditorError(error) { 223 | this.dispatch(error) 224 | } 225 | 226 | dismissEditorError() { 227 | this.dispatch() 228 | } 229 | } 230 | 231 | export default alt.createActions(EditorActions) 232 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import 'babel/polyfill' 2 | 3 | import _ from 'lodash' 4 | import fs from 'fs' 5 | import path from 'path' 6 | import express from 'express' 7 | import http from 'http' 8 | import url from 'url' 9 | import WebSocket from 'ws' 10 | //import compression from 'compression' 11 | 12 | import SwarmServer from './core/swarmserver' 13 | 14 | let redisConfig = { 15 | port: 6379, 16 | host: '127.0.0.1', 17 | options: {} 18 | } 19 | 20 | let swarmServer = new SwarmServer(redisConfig) 21 | let Swarm = swarmServer.Swarm 22 | 23 | let server = express() 24 | let port = process.env.PORT || 5000 25 | server.set('port', port) 26 | //server.use(compression()) 27 | server.use(express.static(path.join(__dirname))) 28 | 29 | // html page template containing placeholders for title and body 30 | const pageTemplateFile = path.join(__dirname, 'templates/index.html') 31 | const pageTemplate = _.template(fs.readFileSync(pageTemplateFile, 'utf8')) 32 | 33 | server.get('/', (req, res) => { 34 | let data = { 35 | description: '', 36 | title: 'Ritzy Editor' 37 | } 38 | 39 | data.content = '' 40 | 41 | let html = pageTemplate(data) 42 | res.send(html) 43 | }) 44 | 45 | // example of calling this: 46 | // http://localhost:5000/sapi/Text%2310 to return a Text replica 10 47 | // http://localhost:5000/sapi/CursorSet%2310 to return a set of Cursors for editor 10 48 | // http://localhost:5000/sapi/Cursor%2310_A0017r to return the state of Cursor for user id A0017r in editor 10 49 | let apiHandler = require('swarm-restapi').createHandler({ 50 | route: '/sapi', 51 | host: Swarm.host, 52 | authenticate: function(req, cb) {cb(null, null)} // no auth, to implement see sample auth function in swarm-restapi/index.js 53 | }) 54 | server.get(/^\/sapi\//, apiHandler) 55 | server.post(/^\/sapi\//, apiHandler) 56 | server.put(/^\/sapi\//, apiHandler) 57 | 58 | let httpServer = http.createServer(server) 59 | 60 | httpServer.listen(server.get('port'), function(err) { 61 | if (err) { 62 | console.warn('Can\'t start HTTP server. Error: ', err, err.stack) 63 | return 64 | } 65 | 66 | // integration with parent process e.g. gulp 67 | // process.send is available if we are a child process (https://nodejs.org/api/child_process.html) 68 | if (process.send) { 69 | process.send('online') 70 | } 71 | console.log('The HTTP server is listening on port ' + server.get('port')) 72 | }) 73 | 74 | // start WebSocket server 75 | let wsServer = new WebSocket.Server({ 76 | server: httpServer 77 | }) 78 | 79 | // accept pipes on connection 80 | wsServer.on('connection', function(ws) { 81 | let params = url.parse(ws.upgradeReq.url, true) 82 | console.log('Incoming websocket %s', params.path, ws.upgradeReq.connection.remoteAddress) 83 | if (!Swarm.host) { 84 | return ws.close() 85 | } 86 | Swarm.host.accept(new Swarm.EinarosWSStream(ws), {delay: 50}) 87 | }) 88 | 89 | /* eslint-disable no-process-exit */ 90 | function onExit(exitCode) { 91 | console.log('Shutting down http-server...') 92 | httpServer.close(function(err) { 93 | if(err) console.warn('HTTP server close failed: %s', err) 94 | else console.log('HTTP server closed.') 95 | }) 96 | 97 | if (!Swarm.host) { 98 | console.log('Swarm host not created yet...') 99 | return process.exit(exitCode) 100 | } 101 | 102 | console.log('Closing swarm host...') 103 | let forcedExit = setTimeout(function() { 104 | console.log('Swarm host close timeout, forcing exit.') 105 | process.exit(exitCode) 106 | }, 5000) 107 | 108 | Swarm.host.close(function() { 109 | console.log('Swarm host closed.') 110 | clearTimeout(forcedExit) 111 | process.exit(exitCode) 112 | }) 113 | } 114 | /* eslint-enable no-process-exit */ 115 | 116 | process.on('SIGTERM', onExit) 117 | process.on('SIGINT', onExit) 118 | process.on('SIGQUIT', onExit) 119 | 120 | process.on('uncaughtException', function(err) { 121 | console.error('Uncaught Exception: ', err, err.stack) 122 | onExit(2) 123 | }) 124 | -------------------------------------------------------------------------------- /src/styles/default-skin.less: -------------------------------------------------------------------------------- 1 | // the editor bounding box (including the left-right and top-bottom text margins) 2 | .text-content-wrapper { 3 | border: dotted black 1px; 4 | } 5 | 6 | // the text bounding box inside the margins 7 | .text-contents { 8 | } 9 | 10 | // selection overlay 11 | .text-selection-overlay { 12 | background-color: #76a7fa; 13 | border-top: 1px solid #76a7fa; 14 | border-bottom: 1px solid #76a7fa; 15 | opacity: 0.50; 16 | } 17 | 18 | // the caret portion of the cursor 19 | .text-cursor-caret { 20 | } 21 | 22 | // the square portion of the cursor above the caret when showing a cursor name 23 | .text-cursor-top { 24 | } 25 | 26 | // the name associated with a cursor 27 | .text-cursor-name { 28 | } 29 | 30 | .ritzy-error-notification { 31 | border: 1px solid; 32 | margin: 10px 0; 33 | padding: 5px; 34 | color: #d8000c; 35 | background: #ffbaba no-repeat 10px center; 36 | } 37 | 38 | .ritzy-error-notification-dismiss { 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/internal.less: -------------------------------------------------------------------------------- 1 | @media print { 2 | .ritzy-internal-ui-unprintable { 3 | display: none!important; 4 | } 5 | } 6 | 7 | .ritzy-internal-text-content-wrapper { 8 | cursor: text; 9 | overflow: hidden; 10 | position: relative; 11 | white-space: normal; 12 | tap-highlight-color: initial; 13 | z-index: 22; 14 | user-select: none; 15 | *, *:before, *:after { 16 | box-sizing: border-box; 17 | } 18 | } 19 | 20 | .ritzy-internal-text-contents { 21 | position: relative; 22 | } 23 | 24 | .ritzy-internal-text-lineview { 25 | position: relative; 26 | } 27 | 28 | .ritzy-internal-text-lineview-content { 29 | white-space: nowrap; 30 | position: absolute; 31 | z-index: 15; 32 | } 33 | 34 | .ritzy-internal-text-lineview-text-block { 35 | white-space: nowrap; 36 | } 37 | 38 | .ritzy-internal-text-cursor { 39 | cursor: text; 40 | position: absolute; 41 | z-index: 24; 42 | } 43 | 44 | .ritzy-internal-text-cursor-caret { 45 | position: absolute; 46 | width: 0; 47 | border-left: 2px solid; 48 | font-size: 0; 49 | } 50 | 51 | .ritzy-internal-text-cursor-top { 52 | position: absolute; 53 | width: 6px; 54 | left: -2px; 55 | top: -2px; 56 | height: 6px; 57 | font-size: 0; 58 | } 59 | 60 | .ritzy-internal-text-cursor-name { 61 | position: absolute; 62 | font-size: 10px; 63 | color: #fff; 64 | top: -14px; 65 | left: -2px; 66 | padding: 2px; 67 | white-space: nowrap; 68 | } 69 | 70 | .ritzy-internal-text-cursor-italic { 71 | display: inline; 72 | transform: rotate(13deg); 73 | } 74 | 75 | .ritzy-internal-text-cursor-blink { 76 | animation-duration: 1s; 77 | animation-iteration-count: infinite; 78 | animation-name: ritzy-internal-text-cursor-fadeoutin; 79 | } 80 | 81 | @keyframes ritzy-internal-text-cursor-fadeoutin { 82 | from { 83 | opacity: 1; 84 | } 85 | 13% { 86 | opacity: 0; 87 | } 88 | 50% { 89 | opacity: 0; 90 | } 91 | 63% { 92 | opacity: 1; 93 | } 94 | to { 95 | opacity: 1; 96 | } 97 | } 98 | 99 | .ritzy-internal-text-selection-overlay { 100 | z-index: 20; 101 | } 102 | 103 | .ritzy-internal-text-selection-overlay.ritzy-internal-text-htmloverlay-under-text { 104 | z-index: 12; 105 | } 106 | 107 | .ritzy-internal-text-htmloverlay { 108 | position: absolute; 109 | z-index: 17; 110 | top: 0; 111 | } 112 | 113 | .ritzy-internal-editor-inline-block { 114 | position: relative; 115 | display: inline-block; 116 | } 117 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%- title %> 7 | 8 | 9 | 23 | 24 | 25 | 28 | 29 |

<%= content %>
30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/tests/karma-test-entry.js: -------------------------------------------------------------------------------- 1 | // require all modules ending in "-testb" from the current directory and all subdirectories 2 | let context = require.context('..', true, /-testb$/) 3 | context.keys().forEach(context) 4 | -------------------------------------------------------------------------------- /src/tests/setupdom.js: -------------------------------------------------------------------------------- 1 | import jsdom from 'jsdom' 2 | 3 | export default function(markup) { 4 | if (typeof document !== 'undefined') return 5 | let doc = jsdom.jsdom(markup || '') 6 | let window = doc.defaultView 7 | 8 | // forward window console output to the NodeJS console 9 | jsdom.getVirtualConsole(window).sendTo(console) 10 | 11 | // setup some references our tests might need 12 | global.document = doc 13 | global.window = window 14 | global.navigator = { 15 | userAgent: 'node.js' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/vendor/swarm/RedisStorage.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable */ 2 | 3 | "use strict"; 4 | var env = require('swarm/lib/env'); 5 | var Spec = require('swarm/lib/Spec'); 6 | var Storage = require('swarm/lib/Storage'); 7 | 8 | /** 9 | * Adaptor for Redis 10 | * @param {{redis:object, redisConnectParams:{unixSocket:string?, port:number?, host:string?, options:object}}} options 11 | * 12 | * 13 | * var storage = new Swarm.RedisStorage('dummy', { 14 | * redis: require('redis'), 15 | * redisConnectParams: { 16 | * port: 6379, 17 | * host: '127.0.0.1', 18 | * options: {} 19 | * } 20 | * }); 21 | * storage.open(callback); 22 | * 23 | * 24 | * @TODO storage opening by host 25 | */ 26 | function RedisStorage (options) { 27 | Storage.call(this); 28 | this.options = options; 29 | this._host = null; // will be set by the Host 30 | this.redis = options.redis; 31 | this.redisConnectParams = options.redisConnectParams || { 32 | unixSocket: undefined, 33 | port: 6379, 34 | host: '127.0.0.1', 35 | options: {} 36 | }; 37 | this.db = null; 38 | this.logtails = {}; 39 | } 40 | RedisStorage.prototype = new Storage(); 41 | module.exports = RedisStorage; 42 | RedisStorage.prototype.isRoot = env.isServer; 43 | 44 | var TAIL_FIELD_SUFFIX = ":log"; 45 | 46 | RedisStorage.prototype.open = function (callback) { 47 | var params = this.redisConnectParams; 48 | if (params.unixSocket) { 49 | this.db = this.redis.createClient(params.unixSocket, params.options || {}); 50 | } else { 51 | this.db = this.redis.createClient(params.port || 6379, params.host || '127.0.0.1', params.options || {}); 52 | } 53 | if(callback) this.db.on('ready', callback); 54 | }; 55 | 56 | RedisStorage.prototype.writeState = function (spec, state, cb) { 57 | if(this.options.debug) console.log('>STATE',state); 58 | var self = this; 59 | var ti = spec.filter('/#'); 60 | //var save = JSON.stringify(state, undefined, 2); 61 | if (!self.db) { 62 | console.warn('the storage is not open', this._host && this._host._id); 63 | return; 64 | } 65 | 66 | var json = JSON.stringify(state); 67 | var cleanup = this.logtails[ti] || []; 68 | delete this.logtails[ti]; 69 | 70 | if(this.options.debug) console.log('>FLUSH',json,cleanup.length); 71 | self.db.set(ti, json, function onSave(err) { 72 | if (!err && cleanup.length && self.db) { 73 | if(self.options.debug) console.log('>CLEAN',cleanup); 74 | cleanup.unshift(ti + TAIL_FIELD_SUFFIX); 75 | self.db.hdel(cleanup, function (err, entriesRemoved) { 76 | err && console.error('log trimming failed',err); 77 | }); 78 | } 79 | err && console.error("state write error", err); 80 | cb(err); 81 | }); 82 | 83 | }; 84 | 85 | RedisStorage.prototype.writeOp = function (spec, value, cb) { 86 | var ti = spec.filter('/#'); 87 | var vo = spec.filter('!.'); 88 | spec = spec.toString(); 89 | var json = JSON.stringify(value); 90 | if(this.options.debug) console.log('>OP', spec, json); 91 | 92 | // store spec in logtail 93 | var log = this.logtails[ti] || (this.logtails[ti] = []); 94 | log.push(vo); 95 | // save op in redis 96 | var logFieldName = ti + TAIL_FIELD_SUFFIX; 97 | this.db.hset(logFieldName, vo, json, function (err) { 98 | err && console.error('op write error',err); 99 | cb(err); 100 | }); 101 | }; 102 | 103 | RedisStorage.prototype.readState = function (ti, callback) { 104 | var self = this; 105 | this.db.get(ti.toString(), function (err, value){ 106 | if (err) { 107 | return callback(err); 108 | } 109 | 110 | if (!value) { 111 | value = {_version: '!0'}; 112 | } else { 113 | value = JSON.parse(value); 114 | } 115 | 116 | if(self.options.debug) console.log('