diff --git a/.gitignore b/.gitignore index fcda3e64..73741658 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .tern-port .npm-debug.log .DS_Store +dist diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..7ed6ff82 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +5 diff --git a/.travis.yml b/.travis.yml index 1f603889..16de9713 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: node_js node_js: - - 0.10 + - "5.11" sudo: false @@ -10,20 +10,6 @@ cache: - node_modules - bower_components -addons: - sauce_connect: true - -env: - matrix: - - BROWSER_NAME='firefox' BROWSER_VERSION='36' PLATFORM='Windows 7' - - BROWSER_NAME='firefox' BROWSER_VERSION='36' PLATFORM='OSX 10.9' - - BROWSER_NAME='chrome' BROWSER_VERSION='39' PLATFORM='OSX 10.8' - - BROWSER_NAME='chrome' BROWSER_VERSION='41' PLATFORM='OSX 10.9' - - BROWSER_NAME='chrome' BROWSER_VERSION='41' PLATFORM='Windows 7' - global: - - secure: OaF1EHbUU+0vO3zdO70VlLVOuojRMnWhF02Lj3S7sP4+lbM3CY3JmEyB8G18G3x2aVyqgBEWLkh5WXahNuQIwnTYfidpbeJhvZD4xcP4zxgVyV6DLbxVN+lUpY2maOgCyDPGAix08HMWwuJss2Cw83bdmm0cJv/r2x6YXa/FpUA= - - secure: AdCl4LiKCMEOBAdltT/aAQVSmqnPZfCc/z2z7VPKCR/s1LbiD1i3ADJin5SgsglhWnmJToam5msNXEvL4gaT0dtlxtiw8ygutzQwEewPSLyekXuAF2NCt+63IdWWt6z470Cb6Nojrz0hRkiKzzeEjF2j1GFmrWkYzgYahlvi4sE= - before_script: - npm install -g bower - bower install diff --git a/BROWSERINCONSISTENCIES.md b/BROWSERINCONSISTENCIES.md index 3e8fd736..6ef1976c 100644 --- a/BROWSERINCONSISTENCIES.md +++ b/BROWSERINCONSISTENCIES.md @@ -73,6 +73,9 @@ Playground: http://jsbin.com/iwEWUXo/2/edit?js,console,output * `subscript`: Firefox: Returns false when a whole `SUB` is selected: http://jsbin.com/marox/1/edit?js,console,output - True for all inline elements? +* Firefox throws `NS_ERROR_UNEXPECTED` error for `insertUnorderedList` and + `insertOrderedList` when a contenteditable is not focussed, see: + https://github.com/guardian/scribe/issues/208 ### `Element.focus` * Firefox: Giving focus to a `contenteditable` will place the caret outside of diff --git a/CHANGELOG.md b/CHANGELOG.md index 46011a2b..e94f8591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,179 @@ +# 3.3.0 + +Merges a range of bug fixes including removing the dependency on lodash, use of ES6 `const` not inserting `
` tags into custom elements and a sanity check to ensure a `listElement` exists before inserting a list. + +Thank you [Dan Burzo](https://github.com/danburzo), [James Lawson](https://github.com/jameslawson), [code-smith](https://github.com/code-smith), [Nazar Mokrynskyi](https://github.com/nazar-pc), [Rob Rees](https://github.com/rrees) and [Oliver J Ash](https://github.com/OliverJAsh) for contributing to these fixes. + +We hope to move towards more granular releases from now on. + +# 3.2.0 + +This changes the key bindings for undo and redo so that they are more specific and should no longer capture the key sequence for certain Polish letters. See #448 for details. + +Thank you [Dan Burzo](https://github.com/danburzo) for contributing this fix. + +# 3.1.0 + +Updates the version of Immutable Scribe uses to 3.8.x. + +# 3.0.0 + +Replaces the last Lodash call with `Object.assign`. This was already available in the browsers Scribe is targeted at but in addition the build process has been changed to use later versions of Node. + +# 2.3.0 + +Introduces a destroy event that plugins can use to clean up after themselves. + +This re-implements an initial implementation by [Craig Speath](https://github.com/craigspaeth), thanks for the contribution. + +# 2.2.5 + +Switches the events from literal strings to using identities from an events module. + +# 2.2.4 + +Attempts to simplify the code in the `inline-elements-mode` plugin as per the suggestions from [Rasmus Schultz](https://github.com/mindplay-dk). + +This change also covers the code with a unit test in case it needs to be modified in future. + +# 2.2.3 + +Removes unneeded paramters from calls to `setStartAfter` and `setEndAfter`. + +Thanks to [Rasmus Schultz](https://github.com/mindplay-dk) for reporting the issue. + +# 2.2.2 + +Removes the observable check function introduced in 2.1.0. As this was not exported I'm treating it as a non-breaking change. + +# 2.2.1 + +Corrects a small style issue where one of the tests was relying on the default coercion of the empty string to the false boolean. The test is now explicit. + +# 2.2.0 + +Addresses issue #456 where one of the core plugins (enforce-p-elements) would wrap empty text nodes in paragraph elements. This behaviour was hidden by the use of the HTML Sanitizer. + +Text nodes consisting just of whitespace are not changed now when the plugin runs. + +Thanks to [Rasmus Schultz](https://github.com/mindplay-dk) for reporting the issue. + +# 2.1.2 + +Fixes an issue where the undo manager could not be disabled due to an unconditional execution of the manager code in the setHTML method (issue #452). + +# 2.1.1 + +Fixes an issue where the window global was still being referenced so the module still couldn't be used server-side. + +# 2.1.0 + +Changes the way the mutation observer is determined and changes the way that nodes with certain classes are checked for. Both of these changes are aimed at offerring better support for server-side rendering. + +Thank you [Sergey Zyablitsky](https://github.com/szyablitsky) and [Simon Degraeve](https://github.com/SimonDegraeve) for your contributions towards this goal. + +# 2.0.2 + +Adds a workaround to allow paste events to work on Android. Thanks to [crasu](https://github.com/crasu) for the contribution. + +# 2.0.1 + +The code for handling manual navigation in list elements now passes the event to its associated command for plugins to use in their response. + +Thank you to [Josh Moore](https://github.com/josh-infusionsoft) for contributing this change. Please raise issues on how you think this should work generally if you are interested. + +# 2.0.0 + +A split text node will [no longer be replaced by a non-breaking backspace](https://github.com/guardian/scribe/pull/421) but instead should be a regular space character. + +Thanks [Jeffrey Wear](https://github.com/wearhere) for this change/fix + +# 1.4.15 + +Stripping of Chrome artifacts has been consolidated into a single function. Thanks [Regis Kuckaertz](https://github.com/regiskuckaertz) + +# 1.4.14 + +The undo manager has been re-written to use Immutable data structures. Thanks [Regis Kuckaertz](https://github.com/regiskuckaertz) + +# 1.4.13 + +A more elegant fix for [#401](https://github.com/guardian/scribe/issues/401) from [Alexy Golev](https://github.com/alexeygolev), thanks! + +# 1.4.12 + +Restores `scribe.element` (lost in release 1.4.9) to avoid breaking backwards compatibility + +# 1.4.11 + +Another attempt to fix [#401](https://github.com/guardian/scribe/issues/401), this time using Immutable data and Array.prototype.slice. + +# 1.4.10 + +The `NS_ERROR_UNEXPECTED` is now caught and supressed. This exeception is being [thrown by Firefox](https://bugzilla.mozilla.org/show_bug.cgi?id=562623) and seems to be a browser specific bug to do with element focus. This change just avoids lots of supurious errors being thrown. + +We should remove it once the bug has been fixed. + +# 1.4.9 + +Consolidates a number of api operations into the node module. + +Restructing by [Regis Kuckaertz](https://github.com/regiskuckaertz) + +# 1.4.8 + +Short-circuits the mutation evaluation via use of Array.prototype.some + +[Regis Kuckaertz](https://github.com/regiskuckaertz) + +# 1.4.7 + +Not a valid build, issues between Bower and NPM + +# 1.4.6 + +Treat the clipboard data types variable as an array to avoid issues with future releases of Chrome (and other browsers). Resolves [#401](https://github.com/guardian/scribe/issues/401). + +# 1.4.5 + +Corrects the NPM version of the ImmutableJS dependency + +# 1.4.4 + +Replaces some of the use of Lodash contains with Immutable data structures and `includes`. + +# 1.4.3 + +Changes the require alias so that the Immutable import path is simplified. + +# 1.4.2 + +A number of performance improvements have been contributed by [Regis Kuckaertz](https://github.com/regiskuckaertz). Primarily these include avoiding TreeWalker where it isn't needed and moving a number of function definitions to the parse phase. See the individual PRs for details. + +# 1.4.1 + +Small optimisation to avoid a relayout as a result of placing Scribe markers. + +Thanks for improvement [Brad Vogel](https://github.com/bradvogel) + +# 1.4.0 + +Changes the cleanup for Chrome inline style tags that happens in the patch for the `insertHTML` command. Previously span tags were aggressively stripped whereas now they are less aggressively removed to limit the fix just to the type of spans that Chrome inserts. + +Thanks [Christopher Liu](https://github.com/christopherliu) for contributing this change. + +# 1.3.9 + +Stops Scribe failing on a focus event if the content of the Scribe element is set to empty. Previously the code assumed that a child node is available, now the focus node will be the parent element if there are no children. + +Based on contributions from [Ryan Fitzgerald](https://github.com/rf-) + +# 1.3.7 + +Fixes a bug where em tags were being stripped where we meant to strip Scribe markers instead. + +Thanks [Abdulrahman Alsaleh](https://github.com/aaalsaleh) for the fix + # 1.3.6 Fixes a bug preventing individual events being switched off events in the event-emitter diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 00000000..8b0942e4 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,50 @@ +module.exports = function(grunt) { + + // Add the grunt-mocha-test tasks. + grunt.loadNpmTasks('grunt-mocha-test'); + grunt.loadNpmTasks('grunt-contrib-requirejs'); + + grunt.initConfig({ + // Configure a mochaTest task + mochaTest: { + test: { + options: { + reporter: 'spec', + }, + src: ['test/**/*.spec.js'] + } + }, + }); + + function requireConfiguration(optimize, outputFilename) { + return { + compile: { + options: { + baseUrl: "src", + name: "scribe", + paths: { + 'lodash-amd': '../bower_components/lodash-amd', + 'immutable': '../bower_components/immutable/dist/immutable' + }, + optimize: optimize, + preserveLicenseComments: false, + generateSourceMaps: true, + out: "build/" + outputFilename + } + } + } + } + + grunt.registerTask('build', 'Build output files', function() { + grunt.config('requirejs', requireConfiguration('uglify2', 'scribe.min.js')); + grunt.task.run('requirejs'); + + grunt.config('requirejs', requireConfiguration('none', 'scribe.js')); + grunt.task.run('requirejs'); + }); + + grunt.registerTask('test', ['mochaTest']); + + grunt.registerTask('default', 'test'); + +}; diff --git a/LICENSE b/LICENSE index 9c8703c5..4aef5caf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,201 @@ -Copyright 2014 Guardian News & Media Ltd + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - http://www.apache.org/licenses/LICENSE-2.0 + 1. Definitions. -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2014-2017 Guardian News & Media Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Plumbing.js b/Plumbing.js index 199a8504..ee29e43c 100644 --- a/Plumbing.js +++ b/Plumbing.js @@ -17,7 +17,7 @@ module.exports = function (pipelines) { preserveLicenseComments: false, paths: { 'lodash-amd': '../bower_components/lodash-amd', - 'immutable': '../bower_components/immutable' + 'immutable': '../bower_components/immutable/dist/immutable' } }); diff --git a/README.md b/README.md index d2113a49..fd57f319 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,40 @@ -Scribe [![Build Status](https://travis-ci.org/guardian/scribe.svg?branch=master)](https://travis-ci.org/guardian/scribe) -====== +**THIS PROJECT IS DEPRECATED** - You can find more information about this in our blog post, [Leaving Scribe](https://www.theguardian.com/info/2019/jan/24/leaving-scribe). In summary: + +- We have no plans to add features to Scribe but may make critical updates throughout the period that we continue to use instances of Scribe internally +- We recommend forking the project in order to do any feature work as we will not be moving the Scribe repository out of the Guardian organisation +- In time we hope to be able to open source the new text editor we are working on + +# Scribe A rich text editor framework for the web platform, with patches for browser inconsistencies and sensible defaults. +## Status + + [![Build Status](https://travis-ci.org/guardian/scribe.svg?branch=master)](https://travis-ci.org/guardian/scribe) [![Join the chat at https://gitter.im/guardian/scribe](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/guardian/scribe?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +## Description + For an introduction, you may want to read the blog post [Inside the Guardian’s CMS: meet Scribe, an extensible rich text editor](http://www.theguardian.com/info/developer-blog/2014/mar/20/inside-the-guardians-cms-meet-scribe-an-extensible-rich-text-editor). -**Please note:** There is a lot of missing documentation for Scribe and many of -its plugins. We plan to improve this, however in the meantime we encourage -you to look at the code. Scribe is very small in comparison to other libraries -of its kind. +**Please note:** There is a lot of missing documentation for Scribe and many of its plugins. We plan to improve this, however in the meantime we encourage you to look at the code. Scribe is very small in comparison to other libraries of its kind. You can join us on IRC at [#scribejs] on freenode, or via the [Google Group](https://groups.google.com/forum/#!forum/scribe-editor). [See an example][example]. +Scribe only actively supports a [sub-set of browsers](https://github.com/guardian/scribe/wiki/Browser-support). + ## Core At the core of Scribe we have: * [Patches for many browser inconsistencies surrounding `contenteditable`](#patches); -* [Inline and block element modes](#modes). +* [Inline and block element modes](https://github.com/guardian/scribe/wiki/Modes#). ### Patches -Scribe patches [many browser inconsistencies][browser inconsistencies] in the -[native command API][Executing Commands]. - -### Modes - -Natively, `contenteditable` will produce DIVs for new lines. This is not a bug. -However, this is not ideal because in most cases we require semantic HTML to be -produced. - -Scribe overrides this behaviour to produce paragraphs (Ps; default) or BRs (with -block element mode turned off) for new lines instead. +Scribe patches [many browser inconsistencies][browser inconsistencies] in the [native command API][Executing Commands]. ## Installation ``` @@ -43,17 +43,6 @@ bower install scribe Alternatively, you can [access the distribution files through GitHub releases](https://github.com/guardian/scribe/releases). -## Options - -
-
allowBlockElements
-
Enable/disable block element mode (enabled by default)
-
defaultCommandPatches
-
Defines which command patches should be loaded by default
-
undo: { enabled: false }
-
Enable/disable Scribe's custom undo manager
-
- ## Usage Example Scribe is an AMD module: @@ -72,11 +61,28 @@ require(['scribe', 'scribe-plugin-blockquote-command', 'scribe-plugin-toolbar'], }); ``` -You can [see a live example here][example], or [view the code here](https://github.com/guardian/scribe/tree/gh-pages). +You can [see a live example here][example], or [view the code here](https://github.com/guardian/scribe). Also be sure to check the [`examples`](./examples) directory for an AMD syntax example as well as a CommonJS (browserify) example. +## Options + +
+
allowBlockElements
+
Enable/disable block element mode (enabled by default)
+
undo: { enabled: false }
+
Enable/disable Scribe's custom undo manager
+
defaultCommandPatches
+
Defines which command patches should be loaded by default
+
defaultPlugins
+
Defines which of Scribe's built-in plugins should be active
+
defaultFormatters
+
Defines which of Scribe's default formatters should be active
+
+ +For detailed documentation see the [wiki page on options](https://github.com/guardian/scribe/wiki/Scribe-configuration-options). + ## Architecture * [Everything is a plugin](https://github.com/guardian/scribe/tree/master/src/plugins). @@ -109,93 +115,17 @@ function myPlugin(scribe) { ### Browser Support -Theoretically, Scribe should work in any browser with the -[Selection][Selection API] API, the [Range][Range API] API, and support for most -of the non-standardised list of commands that appears in -[this MDN article][Executing Commands]. It has been tested in Firefox >= 36, -Chrome >= 41. - -See the [status of our integration tests](https://travis-ci.org/guardian/scribe) -for more up-to-date support information. - - -### Commands - -Commands are objects that describe formatting operations. For example, -the bold command. - -Commands tell Scribe: - -* how to format some HTML when executed (similar to `document.queryCommand`); -* how to query for whether the given command has been executed on the current selection (similar to `document.queryCommandState`); -* how to query for whether the command can be executed on the document in its current state (similar to `document.queryCommandEnabled`) - -To ensure a separation of concerns, commands are split into multiple layers. -When a command method is called by Scribe, it will be filtered through these -layers sequentially. - -
-
Scribe
-
Where custom behaviour is defined.
-
Scribe Patches
-
Where patches for brower inconsistencies in native commands are defined.
-
Native
-
+[Moved to the Github Wiki](https://github.com/guardian/scribe/wiki/Browser-support) ## Plugins -We have created a collection of plugins for advanced rich text editing purposes, -all of which can be seen in use in our [example][example]. - -* [scribe-plugin-blockquote-command](https://github.com/guardian/scribe-plugin-blockquote-command) -* [scribe-plugin-code-command](https://github.com/guardian/scribe-plugin-code-command) -* [scribe-plugin-curly-quotes](https://github.com/guardian/scribe-plugin-curly-quotes) -* [scribe-plugin-formatter-html-ensure-semantic-elements](https://github.com/guardian/scribe-plugin-formatter-html-ensure-semantic-elements) -* [scribe-plugin-formatter-plain-text-convert-new-lines-to-html](https://github.com/guardian/scribe-plugin-formatter-plain-text-convert-new-lines-to-html) -* [scribe-plugin-heading-command](https://github.com/guardian/scribe-plugin-heading-command) -* [scribe-plugin-inline-styles-to-elements](https://github.com/guardian/scribe-plugin-inline-styles-to-elements) -* [scribe-plugin-intelligent-unlink-command](https://github.com/guardian/scribe-plugin-intelligent-unlink-command) -* [scribe-plugin-keyboard-shortcuts](https://github.com/guardian/scribe-plugin-keyboard-shortcuts) -* [scribe-plugin-link-prompt-command](https://github.com/guardian/scribe-plugin-link-prompt-command) -* [scribe-plugin-noting](https://github.com/guardian/scribe-plugin-noting) -* [scribe-plugin-sanitizer](https://github.com/guardian/scribe-plugin-sanitizer) -* [scribe-plugin-smart-lists](https://github.com/guardian/scribe-plugin-smart-lists) -* [scribe-plugin-toolbar](https://github.com/guardian/scribe-plugin-toolbar) - -## FAQ - -### Is it production ready? - -Yes. [The Guardian](http://gu.com) is using Scribe as the basis for their -internal CMS’ rich text editor. - -It is likely that there will be unknown edge cases, but these will be addressed -when they are discovered. +Scribe has a rich plugin ecosystem that expands and customises what it can do. -### How do I run tests? +See the wiki for a [list of plugins and how to create new ones](https://github.com/guardian/scribe/wiki/Plugins) -See [CONTRIBUTING.md](CONTRIBUTING.md) for information about running tests. - -### Why does Scribe have a custom undo manager? - -The [native API for formatting content in a -`contenteditable`][Executing Commands] has [many browser inconsistencies][browser inconsistencies]. -Scribe has to manipulate the DOM directly on top of using these commands in order to patch -those inconsistencies. What’s more, there is no widely supported command for -telling `contenteditable` to insert Ps or BRs for line breaks. Thus, to add -this behaviour Scribe needs to manipulate the DOM once again. - -The undo stack breaks whenever DOM manipulation is used instead of the native -command API, therefore we have to use our own. +## FAQ -Scribe's undo manager can be turned off by configuration eg: -``` js -var scribe = new Scribe(scribeElement, { - undo: { - enabled: false - } -}) -``` +See the wiki's [FAQ](https://github.com/guardian/scribe/wiki/FAQ) [browser inconsistencies]: https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md [Executing Commands]: https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla#Executing_Commands diff --git a/TODO b/TODO deleted file mode 100644 index 1d776dc7..00000000 --- a/TODO +++ /dev/null @@ -1,20 +0,0 @@ -# Other -* Test older Safari versions -* Run integration tests in Safari -* Encrypt/hide access token for Sauce Labs. This environment variable cannot be - encrypted because Travis will not send it to PRs. - See: http://docs.travis-ci.com/user/pull-requests/#Security-Restrictions-when-testing-Pull-Requests - and https://github.com/travis-ci/travis-ci/issues/1946 - -# Feature Development -* Remove event listeners, emit unbind to plugins so that can too -* Grammar for nesting elements, shared with sanitizer/commands -* Intersect commands to run formatters - -# Known Bugs (which we could write failing tests for) -* Focus should select all content -* Applying the outdent command on a top level list item breaks P mode. -* When inserting lists we remove any SPANs from the inside to fix a Chrome bug, - but what if the user actually wants a SPAN? -* Possible to paste blockquotes inside of blockquotes -* Chrome: "1|2", ENTER, + diff --git a/examples/amd.html b/examples/amd.html index ec7362f4..f3f46c93 100644 --- a/examples/amd.html +++ b/examples/amd.html @@ -5,6 +5,8 @@ Note that you'll need to install scribe's dependencies through `bower install`. See http://bower.io/ if you are unfamiliar. --> + + + + +
+ + + + + +
+
+
+

Output

+ +
diff --git a/examples/cjs.html b/examples/cjs.html index b114c763..63d50509 100644 --- a/examples/cjs.html +++ b/examples/cjs.html @@ -6,11 +6,7 @@ through `npm install`. `npm` is installed with Node.js. See http://nodejs.org/ if you are unfamiliar. -In order to compile the `build.js` file, run these commands: - - $ npm install deamdify - $ npm install scribe-plugin-toolbar - $ browserify -g deamdify examples/cjs.js > examples/build.js +In order to compile the `build.js` file, run the following file: build-cjs.sh. See the `examples/cjs.js` file to see the JavaScript logic for this example. --> diff --git a/examples/cjs.js b/examples/cjs.js index 09c28657..1da5dfdc 100644 --- a/examples/cjs.js +++ b/examples/cjs.js @@ -7,11 +7,7 @@ * through `npm install`. `npm` is installed with Node.js. * See http://nodejs.org/ if you are unfamiliar. * - * In order to compile the `build.js` file, run these commands: - * - * $ npm install deamdify - * $ npm install scribe-plugin-toolbar - * $ browserify -g deamdify examples/cjs.js > examples/build.js + * In order to compile the `build.js` file, run the following file: build-cjs.sh. * * See the `examples/cjs.html` file to see where this entry point * ends up being consumed. diff --git a/examples/no-undo-manager.html b/examples/no-undo-manager.html new file mode 100644 index 00000000..9fde4779 --- /dev/null +++ b/examples/no-undo-manager.html @@ -0,0 +1,105 @@ + + + + +
+ + + + + +
+
+
+

Output

+ +
diff --git a/examples/raw-inline.html b/examples/raw-inline.html new file mode 100644 index 00000000..225a68aa --- /dev/null +++ b/examples/raw-inline.html @@ -0,0 +1,91 @@ + + + + +
+ + + +
+
+
+ +
+

Output

+ +
+ +
+

Basic content-editable

+ +
+
diff --git a/examples/raw.html b/examples/raw.html new file mode 100644 index 00000000..f3f9f6bf --- /dev/null +++ b/examples/raw.html @@ -0,0 +1,89 @@ + + + + +
+ + + +
+
+
+ +
+

Output

+ +
+ +
+

Basic content-editable

+ +
+
diff --git a/package.json b/package.json index 31e27fe5..84e261d0 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,29 @@ { "name": "scribe-editor", - "version": "1.3.6", + "version": "3.3.0", + "license": "Apache-2.0", "main": "src/scribe.js", "dependencies": { - "lodash-amd": "~3.5.0", - "immutable": "~3.6.2" + "immutable": "~3.8.1" }, "devDependencies": { "chai": "~1.9.1", - "connect": "~2.12.0", + "grunt": "^1.0", + "grunt-contrib-requirejs": "^1.0.0", + "grunt-mocha-test": "^0.12.7", + "grunt-cli": "^0.1", "http-server": "~0.6.1", - "lodash-node": "~2.4.1", - "mocha": "~1.18.2", - "mversion": "~0.4.3", + "mocha": "~2.4", + "mock-browser": "0.92.11", + "mversion": "~1.10", "node-amd-require": "^0.2.2", - "npm": "^2.5.0", - "plumber": "~0.4.0", - "plumber-all": "~0.4.0", - "plumber-cli": "~0.4.0", - "plumber-glob": "~0.4.0", - "plumber-requirejs": "~0.4.0", - "plumber-uglifyjs": "~0.4.0", - "plumber-write": "~0.4.0", "q": "~1.2", "request": "~2.33.0", - "scribe-test-harness": "~0.0.21", - "selenium-webdriver": "~2.41.0", "sinon": "^1.12.2" }, "scripts": { - "test": "./run-tests.sh" + "test": "node_modules/.bin/grunt test", + "build": "node_modules/.bin/grunt build" }, "repository": { "type": "git", diff --git a/release.sh b/release.sh index c36f9480..b8c05605 100755 --- a/release.sh +++ b/release.sh @@ -13,7 +13,7 @@ git reset --hard git checkout master echo "-- Building distribution files" -$BASE_DIR/node_modules/.bin/plumber build +npm run build echo "-- Copying distribution files to dist branch" git checkout dist diff --git a/run-tests.sh b/run-tests.sh index 4052310c..23113444 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -6,7 +6,7 @@ export TEST_SERVER_PORT=${TEST_SERVER_PORT:=8880} ./node_modules/.bin/http-server -p $TEST_SERVER_PORT --silent & PID=$! -node test/runner +node test/runner $@ TEST_RUNNER_EXIT=$? kill $PID diff --git a/src/api.js b/src/api.js index d2728f0d..19a6fe30 100644 --- a/src/api.js +++ b/src/api.js @@ -1,13 +1,11 @@ define([ './api/command-patch', './api/command', - './api/node', './api/selection', './api/simple-command' ], function ( buildCommandPatch, buildCommand, - Node, buildSelection, buildSimpleCommand ) { @@ -17,7 +15,6 @@ define([ return function Api(scribe) { this.CommandPatch = buildCommandPatch(scribe); this.Command = buildCommand(scribe); - this.Node = Node; this.Selection = buildSelection(scribe); this.SimpleCommand = buildSimpleCommand(this, scribe); }; diff --git a/src/api/node.js b/src/api/node.js deleted file mode 100644 index c2dfde70..00000000 --- a/src/api/node.js +++ /dev/null @@ -1,45 +0,0 @@ -define(function () { - - 'use strict'; - - function Node(node) { - this.node = node; - } - - // TODO: should the return value be wrapped in one of our APIs? - // Node or Selection? - // TODO: write tests. unit or integration? - Node.prototype.getAncestor = function (rootElement, nodeFilter) { - var isTopContainerElement = function (element) { - return rootElement === element; - }; - // TODO: should this happen here? - if (isTopContainerElement(this.node)) { - return; - } - - var currentNode = this.node.parentNode; - - // If it's a `contenteditable` then it's likely going to be the Scribe - // instance, so stop traversing there. - while (currentNode && ! isTopContainerElement(currentNode)) { - if (nodeFilter(currentNode)) { - return currentNode; - } - currentNode = currentNode.parentNode; - } - }; - - Node.prototype.nextAll = function () { - var all = []; - var el = this.node.nextSibling; - while (el) { - all.push(el); - el = el.nextSibling; - } - return all; - }; - - return Node; - -}); diff --git a/src/api/selection.js b/src/api/selection.js index 6f735bc5..922ce6db 100644 --- a/src/api/selection.js +++ b/src/api/selection.js @@ -1,46 +1,90 @@ -define([ - '../element' -], -function (elementHelper) { +define(function () { 'use strict'; return function (scribe) { - /** - * Wrapper for object holding currently selected text. - */ - function Selection() { - var rootDoc = document; + var rootDoc = scribe.el.ownerDocument; + var nodeHelpers = scribe.node; - // find the parent document or document fragment + // find the parent document or document fragment + if( rootDoc.compareDocumentPosition(scribe.el) & Node.DOCUMENT_POSITION_DISCONNECTED ) { var currentElement = scribe.el.parentNode; - while(currentElement && currentElement.nodeType !== Node.DOCUMENT_FRAGMENT_NODE && currentElement.nodeType !== Node.DOCUMENT_NODE) { + while(currentElement && nodeHelpers.isFragment(currentElement)) { currentElement = currentElement.parentNode; } // if we found a document fragment and it has a getSelection method, set it to the root doc - if (currentElement && currentElement.nodeType === Node.DOCUMENT_FRAGMENT_NODE && currentElement.getSelection) { + if (currentElement && currentElement.getSelection) { rootDoc = currentElement; } + } + + function createMarker() { + var node = document.createElement('em'); + node.style.display = 'none'; + node.classList.add('scribe-marker'); + return node; + } + function insertMarker(range, marker) { + range.insertNode(marker); + + /** + * Chrome and Firefox: `Range.insertNode` inserts a bogus text node after + * the inserted element. We just remove it. This in turn creates several + * bugs when perfoming commands on selections that contain an empty text + * node (`removeFormat`, `unlink`). + * As per: http://jsbin.com/hajim/5/edit?js,console,output + */ + if (marker.nextSibling && nodeHelpers.isEmptyTextNode(marker.nextSibling)) { + nodeHelpers.removeNode(marker.nextSibling); + } + + /** + * Chrome and Firefox: `Range.insertNode` inserts a bogus text node before + * the inserted element when the child element is at the start of a block + * element. We just remove it. + * FIXME: Document why we need to remove this + * As per: http://jsbin.com/sifez/1/edit?js,console,output + */ + if (marker.previousSibling && nodeHelpers.isEmptyTextNode(marker.previousSibling)) { + nodeHelpers.removeNode(marker.previousSibling); + } + } + + /** + * Wrapper for object holding currently selected text. + */ + function Selection() { this.selection = rootDoc.getSelection(); if (this.selection.rangeCount && this.selection.anchorNode) { + var startNode = this.selection.anchorNode; + var startOffset = this.selection.anchorOffset; + var endNode = this.selection.focusNode; + var endOffset = this.selection.focusOffset; + + // if the range starts and ends on the same node, then we must swap the + // offsets if ever focusOffset is smaller than anchorOffset + if (startNode === endNode && endOffset < startOffset) { + var tmp = startOffset; + startOffset = endOffset; + endOffset = tmp; + } + // if the range ends *before* it starts, then we must reverse the range + else if (nodeHelpers.isBefore(endNode, startNode)) { + var tmpNode = startNode, + tmpOffset = startOffset; + startNode = endNode; + startOffset = endOffset; + endNode = tmpNode; + endOffset = tmpOffset; + } + // create the range to avoid chrome bug from getRangeAt / window.getSelection() // https://code.google.com/p/chromium/issues/detail?id=380690 this.range = document.createRange(); - var reverseRange = document.createRange(); - - this.range.setStart(this.selection.anchorNode, this.selection.anchorOffset); - reverseRange.setStart(this.selection.focusNode, this.selection.focusOffset); - - // Check if anchorNode is before focusNode, use reverseRange if not - if (this.range.compareBoundaryPoints(Range.START_TO_START, reverseRange) <= 0) { - this.range.setEnd(this.selection.focusNode, this.selection.focusOffset); - } - else { - this.range = reverseRange; - this.range.setEnd(this.selection.anchorNode, this.selection.anchorOffset); - } + this.range.setStart(startNode, startOffset); + this.range.setEnd(endNode, endOffset); } } @@ -51,153 +95,44 @@ function (elementHelper) { var range = this.range; if (!range) { return; } - var node = new scribe.api.Node(this.range.commonAncestorContainer); - var isTopContainerElement = node.node && scribe.el === node.node; - - return ! isTopContainerElement && nodeFilter(node.node) ? node.node : node.getAncestor(scribe.el, nodeFilter); + var node = this.range.commonAncestorContainer; + return ! (node && scribe.el === node) && nodeFilter(node) ? + node : + nodeHelpers.getAncestor(node, scribe.el, nodeFilter); }; + Selection.prototype.isInScribe = function () { + var range = this.range; + return range + //we need to ensure that the scribe's element lives within the current document to avoid errors with the range comparison (see below) + //one way to do this is to check if it's visible (is this the best way?). + && document.contains(scribe.el) + //we want to ensure that the current selection is within the current scribe node + //if this isn't true scribe will place markers within the selections parent + //we want to ensure that scribe ONLY places markers within it's own element + && scribe.el.contains(range.startContainer) + && scribe.el.contains(range.endContainer); + } + Selection.prototype.placeMarkers = function () { var range = this.range; - if (!range) { - return; - } - //we need to ensure that the scribe's element lives within the current document to avoid errors with the range comparison (see below) - //one way to do this is to check if it's visible (is this the best way?). - if (!scribe.el.offsetParent) { + if (!this.isInScribe()) { return; } - //we want to ensure that the current selection is within the current scribe node - //if this isn't true scribe will place markers within the selections parent - //we want to ensure that scribe ONLY places markers within it's own element - var scribeNodeRange = document.createRange(); - scribeNodeRange.selectNodeContents(scribe.el); - - var selectionStartWithinScribeElementStart = this.range.compareBoundaryPoints(Range.START_TO_START, scribeNodeRange) >= 0; - var selectionEndWithinScribeElementEnd = this.range.compareBoundaryPoints(Range.END_TO_END, scribeNodeRange) <= 0; - - if (selectionStartWithinScribeElementStart && selectionEndWithinScribeElementEnd) { - - var startMarker = document.createElement('em'); - startMarker.classList.add('scribe-marker'); - var endMarker = document.createElement('em'); - endMarker.classList.add('scribe-marker'); + // insert start marker + insertMarker(range.cloneRange(), createMarker()); + if (! range.collapsed ) { // End marker - var rangeEnd = this.range.cloneRange(); + var rangeEnd = range.cloneRange(); rangeEnd.collapse(false); - rangeEnd.insertNode(endMarker); - - /** - * Chrome and Firefox: `Range.insertNode` inserts a bogus text node after - * the inserted element. We just remove it. This in turn creates several - * bugs when perfoming commands on selections that contain an empty text - * node (`removeFormat`, `unlink`). - * As per: http://jsbin.com/hajim/5/edit?js,console,output - */ - // TODO: abstract into polyfill for `Range.insertNode` - if (endMarker.nextSibling && - endMarker.nextSibling.nodeType === Node.TEXT_NODE - && endMarker.nextSibling.data === '') { - endMarker.parentNode.removeChild(endMarker.nextSibling); - } - - - - /** - * Chrome and Firefox: `Range.insertNode` inserts a bogus text node before - * the inserted element when the child element is at the start of a block - * element. We just remove it. - * FIXME: Document why we need to remove this - * As per: http://jsbin.com/sifez/1/edit?js,console,output - */ - if (endMarker.previousSibling && - endMarker.previousSibling.nodeType === Node.TEXT_NODE - && endMarker.previousSibling.data === '') { - endMarker.parentNode.removeChild(endMarker.previousSibling); - } - - - /** - * This is meant to test Chrome inserting erroneous text blocks into - * the scribe el when focus switches from a scribe.el to a button to - * the scribe.el. However, this is impossible to simlulate correctly - * in a test. - * - * This behaviour does not happen in Firefox. - * - * See http://jsbin.com/quhin/2/edit?js,output,console - * - * To reproduce the bug, follow the following steps: - * 1. Select text and create H2 - * 2. Move cursor to front of text. - * 3. Remove the H2 by clicking the button - * 4. Observe that you are left with an empty H2 - * after the element. - * - * The problem is caused by the Range being different, depending on - * the position of the marker. - * - * Consider the following two scenarios. - * - * A) - * 1. scribe.el contains: ["1", scribe-marker] - * 2. Click button and click the right of to scribe.el - * 3. scribe.el contains: ["1", scribe-marker. #text] - * - * This is wrong but does not cause the problem. - * - * B) - * 1. scribe.el contains: ["1", scribe-marker] - * 2. Click button and click to left of scribe.el - * 3. scribe.el contains: [#text, scribe-marker, "1"] - * - * The second example sets the range in the wrong place, meaning - * that in the second case the formatBlock is executed on the wrong - * element [the text node] leaving the empty H2 behind. - **/ - - // using range.collapsed vs selection.isCollapsed - https://code.google.com/p/chromium/issues/detail?id=447523 - if (! this.range.collapsed) { - // Start marker - var rangeStart = this.range.cloneRange(); - rangeStart.collapse(true); - rangeStart.insertNode(startMarker); - - /** - * Chrome and Firefox: `Range.insertNode` inserts a bogus text node after - * the inserted element. We just remove it. This in turn creates several - * bugs when perfoming commands on selections that contain an empty text - * node (`removeFormat`, `unlink`). - * As per: http://jsbin.com/hajim/5/edit?js,console,output - */ - // TODO: abstract into polyfill for `Range.insertNode` - if (startMarker.nextSibling && - startMarker.nextSibling.nodeType === Node.TEXT_NODE - && startMarker.nextSibling.data === '') { - startMarker.parentNode.removeChild(startMarker.nextSibling); - } - - /** - * Chrome and Firefox: `Range.insertNode` inserts a bogus text node - * before the inserted element when the child element is at the start of - * a block element. We just remove it. - * FIXME: Document why we need to remove this - * As per: http://jsbin.com/sifez/1/edit?js,console,output - */ - if (startMarker.previousSibling && - startMarker.previousSibling.nodeType === Node.TEXT_NODE - && startMarker.previousSibling.data === '') { - startMarker.parentNode.removeChild(startMarker.previousSibling); - } - } - - - this.selection.removeAllRanges(); - this.selection.addRange(this.range); + insertMarker(rangeEnd, createMarker()); } + + this.selection.removeAllRanges(); + this.selection.addRange(range); }; Selection.prototype.getMarkers = function () { @@ -205,9 +140,13 @@ function (elementHelper) { }; Selection.prototype.removeMarkers = function () { - var markers = this.getMarkers(); - Array.prototype.forEach.call(markers, function (marker) { - marker.parentNode.removeChild(marker); + Array.prototype.forEach.call(this.getMarkers(), function (marker) { + var markerParent = marker.parentNode; + nodeHelpers.removeNode(marker); + // Placing the markers may have split a text node. Sew it up, otherwise + // if the user presses space between the nodes the browser will insert + // an ` ` and that will cause word wrapping issues. + markerParent.normalize(); }); }; @@ -223,13 +162,9 @@ function (elementHelper) { var newRange = document.createRange(); newRange.setStartBefore(markers[0]); - if (markers.length >= 2) { - newRange.setEndAfter(markers[1]); - } else { - // We always reset the end marker because otherwise it will just - // use the current range’s end marker. - newRange.setEndAfter(markers[0]); - } + // We always reset the end marker because otherwise it will just + // use the current range’s end marker. + newRange.setEndAfter(markers.length >= 2 ? markers[1] : markers[0]); if (! keepMarkers) { this.removeMarkers(); @@ -240,37 +175,10 @@ function (elementHelper) { }; Selection.prototype.isCaretOnNewLine = function () { - // return true if nested inline tags ultimately just contain
or "" - function isEmptyInlineElement(node) { - - var treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT, null, false); - - var currentNode = treeWalker.root; - - while(currentNode) { - var numberOfChildren = currentNode.childNodes.length; - - // forks in the tree or text mean no new line - if (numberOfChildren > 1 || - (numberOfChildren === 1 && currentNode.textContent.trim() !== '')) - return false; - - if (numberOfChildren === 0) { - return currentNode.textContent.trim() === ''; - } - - currentNode = treeWalker.nextNode(); - }; - }; - var containerPElement = this.getContaining(function (node) { return node.nodeName === 'P'; }); - if (containerPElement) { - return isEmptyInlineElement(containerPElement); - } else { - return false; - } + return !! containerPElement && nodeHelpers.isEmptyInlineElement(containerPElement); }; return Selection; diff --git a/src/config.js b/src/config.js index 10b1d5db..77f30519 100644 --- a/src/config.js +++ b/src/config.js @@ -1,6 +1,4 @@ -define([ - 'lodash-amd/modern/object/defaults' -], function (defaults) { +define(['immutable'], function (immutable) { var blockModePlugins = [ 'setRootPElement', @@ -36,6 +34,14 @@ define([ ] }; + + function defaults(options, defaultOptions) { + var optionsCopy = immutable.fromJS(options); + var defaultsCopy = immutable.fromJS(defaultOptions); + var mergedOptions = defaultsCopy.merge(optionsCopy); + return mergedOptions.toJS(); + } + /** * Overrides defaults with user's options * diff --git a/src/constants/block-element-names.js b/src/constants/block-element-names.js new file mode 100644 index 00000000..19f78b81 --- /dev/null +++ b/src/constants/block-element-names.js @@ -0,0 +1,11 @@ +define([ + 'immutable' +], function(Immutable) { + var blockElementNames = Immutable.Set.of('ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'CANVAS', 'DD', + 'DIV', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', + 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'LI', + 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TD', + 'TH', 'TFOOT', 'UL', 'VIDEO'); + + return blockElementNames; +}); diff --git a/src/constants/inline-element-names.js b/src/constants/inline-element-names.js new file mode 100644 index 00000000..8cabf14b --- /dev/null +++ b/src/constants/inline-element-names.js @@ -0,0 +1,11 @@ +define([ + 'immutable' +], function (Immutable) { + // Source: https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elemente + var inlineElementNames = Immutable.Set.of('B', 'BIG', 'I', 'SMALL', 'TT', + 'ABBR', 'ACRONYM', 'CITE', 'CODE', 'DFN', 'EM', 'KBD', 'STRONG', 'SAMP', 'VAR', + 'A', 'BDO', 'BR', 'IMG', 'MAP', 'OBJECT', 'Q', 'SCRIPT', 'SPAN', 'SUB', 'SUP', + 'BUTTON', 'INPUT', 'LABEL', 'SELECT', 'TEXTAREA'); + + return inlineElementNames; +}); diff --git a/src/dom-observer.js b/src/dom-observer.js index 4c4b3ce1..64dab35c 100644 --- a/src/dom-observer.js +++ b/src/dom-observer.js @@ -1,32 +1,25 @@ define([ - 'lodash-amd/modern/array/flatten', - 'lodash-amd/modern/lang/toArray', - './element', - './node' -], function ( - flatten, - toArray, - elementHelpers, - nodeHelpers -) { + './node', + './mutations' +], function (nodeHelpers, mutations) { - function observeDomChanges(el, callback) { - function includeRealMutations(mutations) { - var allChangedNodes = flatten(mutations.map(function(mutation) { - var added = toArray(mutation.addedNodes); - var removed = toArray(mutation.removedNodes); - return added.concat(removed); - })); + var maybeWindow = typeof window === 'object' ? window : undefined; - var realChangedNodes = allChangedNodes. - filter(function(n) { return ! nodeHelpers.isEmptyTextNode(n); }). - filter(function(n) { return ! elementHelpers.isSelectionMarkerNode(n); }); + var MutationObserver = mutations.determineMutationObserver(maybeWindow); - return realChangedNodes.length > 0; - } + function hasRealMutation(n) { + return ! nodeHelpers.isEmptyTextNode(n) && + ! nodeHelpers.isSelectionMarkerNode(n); + } - var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; + function includeRealMutations(mutations) { + return mutations.some(function(mutation) { + return Array.prototype.some.call(mutation.addedNodes, hasRealMutation) || + Array.prototype.some.call(mutation.removedNodes, hasRealMutation); + }); + } + function observeDomChanges(el, callback) { // Flag to avoid running recursively var runningPostMutation = false; diff --git a/src/element.js b/src/element.js deleted file mode 100644 index d6c26ee3..00000000 --- a/src/element.js +++ /dev/null @@ -1,36 +0,0 @@ -define(['lodash-amd/modern/collection/contains'], function (contains) { - - 'use strict'; - - var blockElementNames = ['ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'CANVAS', 'DD', - 'DIV', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', - 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'LI', - 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TD', - 'TH', 'TFOOT', 'UL', 'VIDEO']; - function isBlockElement(node) { - return contains(blockElementNames, node.nodeName); - } - - function isSelectionMarkerNode(node) { - return (node.nodeType === Node.ELEMENT_NODE && node.className === 'scribe-marker'); - } - - function isCaretPositionNode(node) { - return (node.nodeType === Node.ELEMENT_NODE && node.className === 'caret-position'); - } - - function unwrap(node, childNode) { - while (childNode.childNodes.length > 0) { - node.insertBefore(childNode.childNodes[0], childNode); - } - node.removeChild(childNode); - } - - return { - isBlockElement: isBlockElement, - isSelectionMarkerNode: isSelectionMarkerNode, - isCaretPositionNode: isCaretPositionNode, - unwrap: unwrap - }; - -}); diff --git a/src/event-emitter.js b/src/event-emitter.js index 915e3bc5..3a9783d2 100644 --- a/src/event-emitter.js +++ b/src/event-emitter.js @@ -1,5 +1,4 @@ -define(['lodash-amd/modern/array/pull', - 'immutable/dist/immutable'], function (pull, Immutable) { +define(['immutable'], function (Immutable) { 'use strict'; diff --git a/src/events.js b/src/events.js new file mode 100644 index 00000000..952e6f13 --- /dev/null +++ b/src/events.js @@ -0,0 +1,10 @@ +define([], function() { + + 'use strict'; + + return { + contentChanged: "scribe:content-changed", + legacyContentChanged: "content-changed", + destroy: "scribe:destroy" + }; +}); diff --git a/src/keystrokes.js b/src/keystrokes.js new file mode 100644 index 00000000..b3ddf387 --- /dev/null +++ b/src/keystrokes.js @@ -0,0 +1,17 @@ +define(function() { + + 'use strict'; + + function isUndoKeyCombination(event) { + return !event.shiftKey && (event.metaKey || (event.ctrlKey && !event.altKey)) && event.keyCode === 90; + } + + function isRedoKeyCombination(event) { + return event.shiftKey && (event.metaKey || (event.ctrlKey && !event.altKey)) && event.keyCode === 90; + } + + return { + isUndoKeyCombination: isUndoKeyCombination, + isRedoKeyCombination: isRedoKeyCombination + }; +}); \ No newline at end of file diff --git a/src/mutations.js b/src/mutations.js new file mode 100644 index 00000000..e315822a --- /dev/null +++ b/src/mutations.js @@ -0,0 +1,22 @@ +define([], function() { + + function determineMutationObserver(window) { + // This enables server side rendering + if (typeof window === 'undefined') { + // Stub observe function to avoid error + return function() { + return { + observe: function() {} + }; + } + } else { + return window.MutationObserver || + window.WebKitMutationObserver || + window.MozMutationObserver; + } + } + + return { + determineMutationObserver: determineMutationObserver + }; +}); diff --git a/src/node.js b/src/node.js index a562b6e5..ed046506 100644 --- a/src/node.js +++ b/src/node.js @@ -1,9 +1,88 @@ -define([], function () { +define([ + './constants/inline-element-names', + './constants/block-element-names', + 'immutable' +], function (inlineElementNames, blockElementNames, Immutable) { 'use strict'; + function isBlockElement(node) { + return blockElementNames.includes(node.nodeName); + } + + function isInlineElement(node) { + return inlineElementNames.includes(node.nodeName); + } + + function hasContent(node) { + + if(node && node.children && node.children.length > 0) { + return true; + } + + if(node && node.nodeName === 'BR') { + return true; + } + return false; + } + + // return true if nested inline tags ultimately just contain
or "" + function isEmptyInlineElement(node) { + if( node.children.length > 1 ) return false; + if( node.children.length === 1 && node.textContent.trim() !== '' ) return false; + if( node.children.length === 0 ) return node.textContent.trim() === ''; + return isEmptyInlineElement(node.children[0]); + } + + function isText(node) { + return node.nodeType === Node.TEXT_NODE; + } + function isEmptyTextNode(node) { - return (node.nodeType === Node.TEXT_NODE && node.textContent === ''); + return isText(node) && node.data === ''; + } + + function isFragment(node) { + return node.nodeType === Node.DOCUMENT_FRAGMENT_NODE; + } + + function isBefore(node1, node2) { + return node1.compareDocumentPosition(node2) & Node.DOCUMENT_POSITION_FOLLOWING; + } + + function elementHasClass(Node, className) { + return function(node) { + return (node.nodeType === Node.ELEMENT_NODE && node.className === className) + } + } + + function isSelectionMarkerNode(node) { + return elementHasClass(Node, 'scribe-marker')(node); + } + + function isCaretPositionNode(node) { + return elementHasClass(Node, 'caret-position')(node); + } + + function isWhitespaceOnlyTextNode(Node, node) { + if(node.nodeType === Node.TEXT_NODE + && /^\s*$/.test(node.nodeValue)) { + return true; + } + + return false; + + } + + function isTextNodeWithContent(Node, node) { + return node.nodeType === Node.TEXT_NODE && !isWhitespaceOnlyTextNode(Node, node); + } + + function firstDeepestChild(node) { + var fs = node.firstChild; + return !fs || fs.nodeName === 'BR' ? + node : + firstDeepestChild(fs); } function insertAfter(newNode, referenceNode) { @@ -14,10 +93,109 @@ define([], function () { return node.parentNode.removeChild(node); } + function getAncestor(node, rootElement, nodeFilter) { + function isTopContainerElement (element) { + return rootElement === element; + } + // TODO: should this happen here? + if (isTopContainerElement(node)) { + return; + } + + var currentNode = node.parentNode; + + // If it's a `contenteditable` then it's likely going to be the Scribe + // instance, so stop traversing there. + while (currentNode && ! isTopContainerElement(currentNode)) { + if (nodeFilter(currentNode)) { + return currentNode; + } + currentNode = currentNode.parentNode; + } + } + + function nextSiblings(node) { + var all = Immutable.List(); + while (node = node.nextSibling) { + all = all.push(node); + } + return all; + } + + function wrap(nodes, parentNode) { + nodes[0].parentNode.insertBefore(parentNode, nodes[0]); + nodes.forEach(function (node) { + parentNode.appendChild(node); + }); + return parentNode; + } + + function unwrap(node, childNode) { + while (childNode.childNodes.length > 0) { + node.insertBefore(childNode.childNodes[0], childNode); + } + node.removeChild(childNode); + } + + /** + * Chrome: If a parent node has a CSS `line-height` when we apply the + * insertHTML command, Chrome appends a SPAN to plain content with + * inline styling replicating that `line-height`, and adjusts the + * `line-height` on inline elements. + * + * As per: http://jsbin.com/ilEmudi/4/edit?css,js,output + * More from the web: http://stackoverflow.com/q/15015019/40352 + */ + function removeChromeArtifacts(parentElement) { + function isInlineWithStyle(parentStyle, element) { + return window.getComputedStyle(element).lineHeight === parentStyle.lineHeight; + } + + var nodes = Immutable.List(parentElement.querySelectorAll(inlineElementNames + .map(function(elName) { return elName + '[style*="line-height"]' }) + .join(',') + )); + nodes = nodes.filter(isInlineWithStyle.bind(null, window.getComputedStyle(parentElement))); + + var emptySpans = Immutable.List(); + + nodes.forEach(function(node) { + node.style.lineHeight = null; + if (node.getAttribute('style') === '') { + node.removeAttribute('style'); + } + if (node.nodeName === 'SPAN' && node.attributes.length === 0) { + emptySpans = emptySpans.push(node); + } + }); + + emptySpans.forEach(function(node) { + unwrap(node.parentNode, node); + }); + } + return { + isInlineElement: isInlineElement, + isBlockElement: isBlockElement, + isEmptyInlineElement: isEmptyInlineElement, + isText: isText, isEmptyTextNode: isEmptyTextNode, + isWhitespaceOnlyTextNode: isWhitespaceOnlyTextNode, + isTextNodeWithContent: isTextNodeWithContent, + isFragment: isFragment, + isBefore: isBefore, + isSelectionMarkerNode: isSelectionMarkerNode, + isCaretPositionNode: isCaretPositionNode, + firstDeepestChild: firstDeepestChild, insertAfter: insertAfter, - removeNode: removeNode + removeNode: removeNode, + getAncestor: getAncestor, + nextSiblings: nextSiblings, + wrap: wrap, + unwrap: unwrap, + removeChromeArtifacts: removeChromeArtifacts, + elementHasClass: elementHasClass, + hasContent: hasContent }; }); diff --git a/src/plugins/core/commands/insert-list.js b/src/plugins/core/commands/insert-list.js index 2550c58c..c2b9fc20 100644 --- a/src/plugins/core/commands/insert-list.js +++ b/src/plugins/core/commands/insert-list.js @@ -1,4 +1,6 @@ -define(function () { +define([ + 'immutable' +], function (Immutable) { /** * If the paragraphs option is set to true, then when the list is @@ -9,6 +11,8 @@ define(function () { return function () { return function (scribe) { + var nodeHelpers = scribe.node; + var InsertListCommand = function (commandName) { scribe.api.Command.call(this, commandName); }; @@ -18,12 +22,13 @@ define(function () { InsertListCommand.prototype.execute = function (value) { function splitList(listItemElements) { - if (listItemElements.length > 0) { + if (!!listItemElements.size) { var newListNode = document.createElement(listNode.nodeName); - listItemElements.forEach(function (listItemElement) { - newListNode.appendChild(listItemElement); - }); + while (!!listItemElements.size) { + newListNode.appendChild(listItemElements.first()); + listItemElements = listItemElements.shift(); + } listNode.parentNode.insertBefore(newListNode, listNode.nextElementSibling); } @@ -43,7 +48,7 @@ define(function () { scribe.transactionManager.run(function () { if (listItemElement) { - var nextListItemElements = (new scribe.api.Node(listItemElement)).nextAll(); + var nextListItemElements = nodeHelpers.nextSiblings(listItemElement); /** * If we are not at the start or end of a UL/OL, we have to @@ -70,15 +75,12 @@ define(function () { // We can't query for list items in the selection so we loop // through them all and find the intersection ourselves. - var selectedListItemElements = Array.prototype.map.call(listNode.querySelectorAll('li'), - function (listItemElement) { - return range.intersectsNode(listItemElement) && listItemElement; - }).filter(function (listItemElement) { - // TODO: identity - return listItemElement; - }); - var lastSelectedListItemElement = selectedListItemElements.slice(-1)[0]; - var listItemElementsAfterSelection = (new scribe.api.Node(lastSelectedListItemElement)).nextAll(); + var selectedListItemElements = Immutable.List(listNode.querySelectorAll('li')) + .filter(function (listItemElement) { + return range.intersectsNode(listItemElement); + }); + var lastSelectedListItemElement = selectedListItemElements.last(); + var listItemElementsAfterSelection = nodeHelpers.nextSiblings(lastSelectedListItemElement); /** * If we are not at the start or end of a UL/OL, we have to diff --git a/src/plugins/core/commands/redo.js b/src/plugins/core/commands/redo.js index 7bfce24a..e7516fec 100644 --- a/src/plugins/core/commands/redo.js +++ b/src/plugins/core/commands/redo.js @@ -1,4 +1,6 @@ -define(function () { +define([ + '../../../keystrokes' +], function (keystrokes) { 'use strict'; @@ -19,7 +21,7 @@ define(function () { //is scribe is configured to undo assign listener if (scribe.options.undo.enabled) { scribe.el.addEventListener('keydown', function (event) { - if (event.shiftKey && (event.metaKey || event.ctrlKey) && event.keyCode === 90) { + if (keystrokes.isRedoKeyCombination(event)) { event.preventDefault(); redoCommand.execute(); } diff --git a/src/plugins/core/commands/undo.js b/src/plugins/core/commands/undo.js index 19cc2e05..22b7be3d 100644 --- a/src/plugins/core/commands/undo.js +++ b/src/plugins/core/commands/undo.js @@ -1,4 +1,6 @@ -define(function () { +define([ + '../../../keystrokes' +], function (keystrokes) { 'use strict'; @@ -18,8 +20,7 @@ define(function () { if (scribe.options.undo.enabled) { scribe.el.addEventListener('keydown', function (event) { - // TODO: use lib to abstract meta/ctrl keys? - if (! event.shiftKey && (event.metaKey || event.ctrlKey) && event.keyCode === 90) { + if (keystrokes.isUndoKeyCombination(event)) { event.preventDefault(); undoCommand.execute(); } diff --git a/src/plugins/core/events.js b/src/plugins/core/events.js index 05756527..8f40bf12 100644 --- a/src/plugins/core/events.js +++ b/src/plugins/core/events.js @@ -1,15 +1,17 @@ define([ - 'lodash-amd/modern/collection/contains', - '../../dom-observer' + '../../dom-observer', + 'immutable' ], function ( - contains, - observeDomChanges + observeDomChanges, + Immutable ) { 'use strict'; return function () { return function (scribe) { + var nodeHelpers = scribe.node; + /** * Firefox: Giving focus to a `contenteditable` will place the caret * outside of any block elements. Chrome behaves correctly by placing the @@ -28,7 +30,7 @@ define([ selection.range.startContainer === scribe.el; if (isFirefoxBug) { - var focusElement = getFirstDeepestChild(scribe.el.firstChild); + var focusElement = nodeHelpers.firstDeepestChild(scribe.el); var range = selection.range; @@ -39,22 +41,6 @@ define([ selection.selection.addRange(range); } } - - function getFirstDeepestChild(node) { - var treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ALL, null, false); - var previousNode = treeWalker.currentNode; - if (treeWalker.firstChild()) { - // TODO: build list of non-empty elements (used elsewhere) - // Do not include non-empty elements - if (treeWalker.currentNode.nodeName === 'BR') { - return previousNode; - } else { - return getFirstDeepestChild(treeWalker.currentNode); - } - } else { - return treeWalker.currentNode; - } - } }.bind(scribe)); /** @@ -116,7 +102,7 @@ define([ */ if (headingNode && range.collapsed) { var contentToEndRange = range.cloneRange(); - contentToEndRange.setEndAfter(headingNode, 0); + contentToEndRange.setEndAfter(headingNode); // Get the content from the range to the end of the heading var contentToEndFragment = contentToEndRange.cloneContents(); @@ -174,6 +160,8 @@ define([ var command = scribe.getCommand(listNode.nodeName === 'OL' ? 'insertOrderedList' : 'insertUnorderedList'); + command.event = event; + command.execute(); } } @@ -196,12 +184,13 @@ define([ /** * Browsers without the Clipboard API (specifically `ClipboardEvent.clipboardData`) * will execute the second branch here. + * + * Chrome on android provides `ClipboardEvent.clipboardData` but the types array is not filled */ - if (event.clipboardData) { + if (event.clipboardData && event.clipboardData.types.length > 0) { event.preventDefault(); - if (contains(event.clipboardData.types, 'text/html')) { - + if (Immutable.List(event.clipboardData.types).includes('text/html')) { scribe.insertHTML(event.clipboardData.getData('text/html')); } else { scribe.insertPlainText(event.clipboardData.getData('text/plain')); diff --git a/src/plugins/core/formatters/html/enforce-p-elements.js b/src/plugins/core/formatters/html/enforce-p-elements.js index 77ed8b19..3092767f 100644 --- a/src/plugins/core/formatters/html/enforce-p-elements.js +++ b/src/plugins/core/formatters/html/enforce-p-elements.js @@ -1,8 +1,6 @@ define([ - 'lodash-amd/modern/array/last' -], function ( - last -) { + 'immutable' +], function (Immutable) { /** * Chrome and Firefox: Upon pressing backspace inside of a P, the @@ -21,69 +19,43 @@ define([ 'use strict'; - /** - * Wrap consecutive inline elements and text nodes in a P element. - */ - function wrapChildNodes(scribe, parentNode) { - var groups = Array.prototype.reduce.call(parentNode.childNodes, - function (accumulator, binChildNode) { - var group = last(accumulator); - if (! group) { - startNewGroup(); - } else { - var isBlockGroup = scribe.element.isBlockElement(group[0]); - if (isBlockGroup === scribe.element.isBlockElement(binChildNode)) { - group.push(binChildNode); - } else { - startNewGroup(); - } - } - - return accumulator; - - function startNewGroup() { - var newGroup = [binChildNode]; - accumulator.push(newGroup); + return function () { + return function (scribe) { + var nodeHelpers = scribe.node; + + /** + * Wrap consecutive inline elements and text nodes in a P element. + */ + function wrapChildNodes(parentNode) { + var index = 0; + + Immutable.List(parentNode.childNodes) + .filterNot(function(node) { + return nodeHelpers.isWhitespaceOnlyTextNode(Node, node); + }) + .filter(function(node) { + return node.nodeType === Node.TEXT_NODE || !nodeHelpers.isBlockElement(node); + }) + .groupBy(function(node, key, list) { + return key === 0 || node.previousSibling === list.get(key - 1) ? + index : + index += 1; + }) + .forEach(function(nodeGroup) { + nodeHelpers.wrap(nodeGroup.toArray(), document.createElement('p')); + }); } - }, []); - var consecutiveInlineElementsAndTextNodes = groups.filter(function (group) { - var isBlockGroup = scribe.element.isBlockElement(group[0]); - return ! isBlockGroup; - }); + // Traverse the tree, wrapping child nodes as we go. + function traverse(parentNode) { + var i = 0, node; - consecutiveInlineElementsAndTextNodes.forEach(function (nodes) { - var pElement = document.createElement('p'); - nodes[0].parentNode.insertBefore(pElement, nodes[0]); - nodes.forEach(function (node) { - pElement.appendChild(node); - }); - }); - - parentNode._isWrapped = true; - } - - // Traverse the tree, wrapping child nodes as we go. - function traverse(scribe, parentNode) { - var treeWalker = document.createTreeWalker(parentNode, NodeFilter.SHOW_ELEMENT, null, false); - var node = treeWalker.firstChild(); - - // FIXME: does this recurse down? - - while (node) { - // TODO: At the moment we only support BLOCKQUOTEs. See failing - // tests. - if (node.nodeName === 'BLOCKQUOTE' && ! node._isWrapped) { - wrapChildNodes(scribe, node); - traverse(scribe, parentNode); - break; + while (node = parentNode.children[i++]) { + if (node.tagName === 'BLOCKQUOTE') { + wrapChildNodes(node); + } + } } - node = treeWalker.nextSibling(); - } - } - - return function () { - return function (scribe) { scribe.registerHTMLFormatter('normalize', function (html) { /** @@ -98,8 +70,8 @@ define([ var bin = document.createElement('div'); bin.innerHTML = html; - wrapChildNodes(scribe, bin); - traverse(scribe, bin); + wrapChildNodes(bin); + traverse(bin); return bin.innerHTML; }); diff --git a/src/plugins/core/formatters/html/ensure-selectable-containers.js b/src/plugins/core/formatters/html/ensure-selectable-containers.js index 2f6774af..989b22cf 100644 --- a/src/plugins/core/formatters/html/ensure-selectable-containers.js +++ b/src/plugins/core/formatters/html/ensure-selectable-containers.js @@ -1,9 +1,9 @@ define([ - '../../../../element', - 'lodash-amd/modern/collection/contains' + '../../../../node', + 'immutable' ], function ( - element, - contains + nodeHelpers, + Immutable ) { /** @@ -15,10 +15,10 @@ define([ 'use strict'; // http://www.w3.org/TR/html-markup/syntax.html#syntax-elements - var html5VoidElements = ['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']; + var html5VoidElements = Immutable.Set.of('AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'); - function parentHasNoTextContent(element, node) { - if (element.isCaretPositionNode(node)) { + function parentHasNoTextContent(node) { + if (nodeHelpers.isCaretPositionNode(node)) { return true; } else { return node.parentNode.textContent.trim() === ''; @@ -26,7 +26,7 @@ define([ } - function traverse(element, parentNode) { + function traverse(parentNode) { // Instead of TreeWalker, which gets confused when the BR is added to the dom, // we recursively traverse the tree to look for an empty node that can have childNodes @@ -34,29 +34,30 @@ define([ function isEmpty(node) { - if ((node.children.length === 0 && element.isBlockElement(node)) - || (node.children.length === 1 && element.isSelectionMarkerNode(node.children[0]))) { + if ((node.children.length === 0 && nodeHelpers.isBlockElement(node)) + || (node.children.length === 1 && nodeHelpers.isSelectionMarkerNode(node.children[0]))) { return true; } // Do not insert BR in empty non block elements with parent containing text - if (!element.isBlockElement(node) && node.children.length === 0) { - return parentHasNoTextContent(element, node); + if (!nodeHelpers.isBlockElement(node) && node.children.length === 0) { + return parentHasNoTextContent(node); } return false; } while (node) { - if (!element.isSelectionMarkerNode(node)) { + if (!nodeHelpers.isSelectionMarkerNode(node)) { // Find any node that contains no child *elements*, or just contains - // whitespace, and is not self-closing + // whitespace, is not self-closing and is not a custom element if (isEmpty(node) && node.textContent.trim() === '' && - !contains(html5VoidElements, node.nodeName)) { + !html5VoidElements.includes(node.nodeName) && + node.nodeName.indexOf('-') === -1) { node.appendChild(document.createElement('br')); } else if (node.children.length > 0) { - traverse(element, node); + traverse(node); } } node = node.nextElementSibling; @@ -70,7 +71,7 @@ define([ var bin = document.createElement('div'); bin.innerHTML = html; - traverse(scribe.element, bin); + traverse(bin); return bin.innerHTML; }); diff --git a/src/plugins/core/formatters/plain-text/escape-html-characters.js b/src/plugins/core/formatters/plain-text/escape-html-characters.js index 319a9cd7..8c55a890 100644 --- a/src/plugins/core/formatters/plain-text/escape-html-characters.js +++ b/src/plugins/core/formatters/plain-text/escape-html-characters.js @@ -1,3 +1,99 @@ +define('lodash-amd/modern/internal/baseToString',[], function() { + + /** + * Converts `value` to a string if it is not one. An empty string is returned + * for `null` or `undefined` values. + * + * @private + * @param {*} value The value to process. + * @returns {string} Returns the string. + */ + function baseToString(value) { + if (typeof value == 'string') { + return value; + } + return value == null ? '' : (value + ''); + } + + return baseToString; +}); + +define('lodash-amd/modern/internal/escapeHtmlChar',[], function() { + + /** Used to map characters to HTML entities. */ + var htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`' + }; + + /** + * Used by `_.escape` to convert characters to HTML entities. + * + * @private + * @param {string} chr The matched character to escape. + * @returns {string} Returns the escaped character. + */ + function escapeHtmlChar(chr) { + return htmlEscapes[chr]; + } + + return escapeHtmlChar; +}); + +define('lodash-amd/modern/string/escape',['../internal/baseToString', '../internal/escapeHtmlChar'], function(baseToString, escapeHtmlChar) { + + /** Used to match HTML entities and HTML characters. */ + var reUnescapedHtml = /[&<>"'`]/g, + reHasUnescapedHtml = RegExp(reUnescapedHtml.source); + + /** + * Converts the characters "&", "<", ">", '"', "'", and "\`", in `string` to + * their corresponding HTML entities. + * + * **Note:** No other characters are escaped. To escape additional characters + * use a third-party library like [_he_](https://mths.be/he). + * + * Though the ">" character is escaped for symmetry, characters like + * ">" and "/" don't require escaping in HTML and have no special meaning + * unless they're part of a tag or unquoted attribute value. + * See [Mathias Bynens's article](https://mathiasbynens.be/notes/ambiguous-ampersands) + * (under "semi-related fun fact") for more details. + * + * Backticks are escaped because in Internet Explorer < 9, they can break out + * of attribute values or HTML comments. See [#102](https://html5sec.org/#102), + * [#108](https://html5sec.org/#108), and [#133](https://html5sec.org/#133) of + * the [HTML5 Security Cheatsheet](https://html5sec.org/) for more details. + * + * When working with HTML you should always quote attribute values to reduce + * XSS vectors. See [Ryan Grove's article](http://wonko.com/post/html-escaping) + * for more details. + * + * @static + * @memberOf _ + * @category String + * @param {string} [string=''] The string to escape. + * @returns {string} Returns the escaped string. + * @example + * + * _.escape('fred, barney, & pebbles'); + * // => 'fred, barney, & pebbles' + */ + function escape(string) { + // Reset `lastIndex` because in IE < 9 `String#replace` does not. + string = baseToString(string); + return (string && reHasUnescapedHtml.test(string)) + ? string.replace(reUnescapedHtml, escapeHtmlChar) + : string; + } + + return escape; +}); + + define([ 'lodash-amd/modern/string/escape' ], function ( diff --git a/src/plugins/core/inline-elements-mode.js b/src/plugins/core/inline-elements-mode.js index 5cd67be3..74b1ccca 100644 --- a/src/plugins/core/inline-elements-mode.js +++ b/src/plugins/core/inline-elements-mode.js @@ -1,4 +1,4 @@ -define(function () { +define(['../../node'], function (nodeHelpers) { 'use strict'; @@ -8,8 +8,9 @@ define(function () { while (treeWalker.nextNode()) { if (treeWalker.currentNode) { + // If the node is a non-empty element or has content - if (~['br'].indexOf(treeWalker.currentNode.nodeName.toLowerCase()) || treeWalker.currentNode.length > 0) { + if(nodeHelpers.hasContent(treeWalker.currentNode) || nodeHelpers.isTextNodeWithContent(Node, treeWalker.currentNode)) { return true; } } @@ -40,18 +41,32 @@ define(function () { event.preventDefault(); scribe.transactionManager.run(function () { + + if (!range.collapsed) { + range.deleteContents(); + } + + /** * Firefox: Delete the bogus BR as we insert another one later. * We have to do this because otherwise the browser will believe * there is content to the right of the selection. */ - if (scribe.el.lastChild.nodeName === 'BR') { + if (scribe.el.lastChild && scribe.el.lastChild.nodeName === 'BR') { scribe.el.removeChild(scribe.el.lastChild); } var brNode = document.createElement('br'); range.insertNode(brNode); + + // Safari does not update the endoffset after inserting the BR element + // so we have to do it ourselves. + // References: + // https://bugs.webkit.org/show_bug.cgi?id=63538#c3 + // https://dom.spec.whatwg.org/#dom-range-selectnode + range.setEndAfter(brNode); + // After inserting the BR into the range is no longer collapsed, so // we have to collapse it again. // TODO: Older versions of Firefox require this argument even though @@ -79,7 +94,9 @@ define(function () { */ var contentToEndRange = range.cloneRange(); - contentToEndRange.setEndAfter(scribe.el.lastChild, 0); + if (scribe.el.lastChild) { + contentToEndRange.setEndAfter(scribe.el.lastChild); + } // Get the content from the range to the end of the heading var contentToEndFragment = contentToEndRange.cloneContents(); @@ -93,8 +110,8 @@ define(function () { var newRange = range.cloneRange(); - newRange.setStartAfter(brNode, 0); - newRange.setEndAfter(brNode, 0); + newRange.setStartAfter(brNode); + newRange.setEndAfter(brNode); selection.selection.removeAllRanges(); selection.selection.addRange(newRange); diff --git a/src/plugins/core/patches/commands/create-link.js b/src/plugins/core/patches/commands/create-link.js index 581888a4..fe5a4dbe 100644 --- a/src/plugins/core/patches/commands/create-link.js +++ b/src/plugins/core/patches/commands/create-link.js @@ -10,6 +10,14 @@ define(function () { createLinkCommand.execute = function (value) { var selection = new scribe.api.Selection(); + /** + * make sure we're not touching any none Scribe elements + * in the page + */ + if (!selection.isInScribe()) { + return; + } + /** * Firefox does not create a link when selection is collapsed * so we create it manually. http://jsbin.com/tutufi/2/edit?js,output diff --git a/src/plugins/core/patches/commands/insert-html.js b/src/plugins/core/patches/commands/insert-html.js index 3c9145e8..14451e62 100644 --- a/src/plugins/core/patches/commands/insert-html.js +++ b/src/plugins/core/patches/commands/insert-html.js @@ -1,57 +1,14 @@ define([], function () { - - 'use strict'; - + "use strict"; return function () { return function (scribe) { var insertHTMLCommandPatch = new scribe.api.CommandPatch('insertHTML'); - var element = scribe.element; + var nodeHelpers = scribe.node; insertHTMLCommandPatch.execute = function (value) { scribe.transactionManager.run(function () { scribe.api.CommandPatch.prototype.execute.call(this, value); - - /** - * Chrome: If a parent node has a CSS `line-height` when we apply the - * insertHTML command, Chrome appends a SPAN to plain content with - * inline styling replicating that `line-height`, and adjusts the - * `line-height` on inline elements. - * As per: http://jsbin.com/ilEmudi/4/edit?css,js,output - * - * FIXME: what if the user actually wants to use SPANs? This could - * cause conflicts. - */ - - // TODO: share somehow with similar event patch for P nodes - sanitize(scribe.el); - - function sanitize(parentNode) { - var treeWalker = document.createTreeWalker(parentNode, NodeFilter.SHOW_ELEMENT, null, false); - var node = treeWalker.firstChild(); - if (!node) { return; } - - do { - if (node.nodeName === 'SPAN') { - element.unwrap(parentNode, node); - } else { - /** - * If the list item contains inline elements such as - * A, B, or I, Chrome will also append an inline style for - * `line-height` on those elements, so we remove it here. - */ - node.style.lineHeight = null; - - // There probably wasn’t a `style` attribute before, so - // remove it if it is now empty. - if (node.getAttribute('style') === '') { - node.removeAttribute('style'); - } - } - - // Sanitize children - sanitize(node); - } while ((node = treeWalker.nextSibling())); - } + nodeHelpers.removeChromeArtifacts(scribe.el); }.bind(this)); }; diff --git a/src/plugins/core/patches/commands/insert-list.js b/src/plugins/core/patches/commands/insert-list.js index bd037aae..10c8dd25 100644 --- a/src/plugins/core/patches/commands/insert-list.js +++ b/src/plugins/core/patches/commands/insert-list.js @@ -4,7 +4,6 @@ define([], function () { return function () { return function (scribe) { - var element = scribe.element; var nodeHelpers = scribe.node; var InsertListCommandPatch = function (commandName) { @@ -25,25 +24,25 @@ define([], function () { return node.nodeName === 'OL' || node.nodeName === 'UL'; }); + if (listElement) { - /** - * Firefox: If we apply the insertOrderedList or the insertUnorderedList - * command on an empty block, a P will be inserted after the OL/UL. - * As per: http://jsbin.com/cubacoli/3/edit?html,js,output - */ + /** + * Firefox: If we apply the insertOrderedList or the insertUnorderedList + * command on an empty block, a P will be inserted after the OL/UL. + * As per: http://jsbin.com/cubacoli/3/edit?html,js,output + */ - if (listElement.nextElementSibling && - listElement.nextElementSibling.childNodes.length === 0) { - nodeHelpers.removeNode(listElement.nextElementSibling); - } + if (listElement.nextElementSibling && + listElement.nextElementSibling.childNodes.length === 0) { + nodeHelpers.removeNode(listElement.nextElementSibling); + } - /** - * Chrome: If we apply the insertOrderedList or the insertUnorderedList - * command on an empty block, the OL/UL will be nested inside the block. - * As per: http://jsbin.com/eFiRedUc/1/edit?html,js,output - */ + /** + * Chrome: If we apply the insertOrderedList or the insertUnorderedList + * command on an empty block, the OL/UL will be nested inside the block. + * As per: http://jsbin.com/eFiRedUc/1/edit?html,js,output + */ - if (listElement) { var listParentNode = listElement.parentNode; // If list is within a text block then split that block if (listParentNode && /^(H[1-6]|P)$/.test(listParentNode.nodeName)) { @@ -65,50 +64,26 @@ define([], function () { nodeHelpers.removeNode(listParentNode); } } - } - /** - * Chrome: If a parent node has a CSS `line-height` when we apply the - * insertOrderedList or the insertUnorderedList command, Chrome appends - * a SPAN to LIs with inline styling replicating that `line-height`. - * As per: http://jsbin.com/OtemujAY/7/edit?html,css,js,output - * - * FIXME: what if the user actually wants to use SPANs? This could - * cause conflicts. - */ - - // TODO: share somehow with similar event patch for P nodes - var listItemElements = Array.prototype.slice.call(listElement.childNodes); - listItemElements.forEach(function(listItemElement) { - // We clone the childNodes into an Array so that it's - // not affected by any manipulation below when we - // iterate over it - var listItemElementChildNodes = Array.prototype.slice.call(listItemElement.childNodes); - listItemElementChildNodes.forEach(function(listElementChildNode) { - if (listElementChildNode.nodeName === 'SPAN') { - // Unwrap any SPAN that has been inserted - var spanElement = listElementChildNode; - element.unwrap(listItemElement, spanElement); - } else if (listElementChildNode.nodeType === Node.ELEMENT_NODE) { - /** - * If the list item contains inline elements such as - * A, B, or I, Chrome will also append an inline style for - * `line-height` on those elements, so we remove it here. - */ - listElementChildNode.style.lineHeight = null; - - // There probably wasn’t a `style` attribute before, so - // remove it if it is now empty. - if (listElementChildNode.getAttribute('style') === '') { - listElementChildNode.removeAttribute('style'); - } - } - }); - }); + nodeHelpers.removeChromeArtifacts(listElement); + } } }.bind(this)); }; + InsertListCommandPatch.prototype.queryState = function() { + try { + return scribe.api.CommandPatch.prototype.queryState.apply(this, arguments); + } catch (err) { + // Explicitly catch unexpected error when calling queryState - bug in Firefox: https://github.com/guardian/scribe/issues/208 + if (err.name == 'NS_ERROR_UNEXPECTED') { + return false; + } else { + throw err; + } + } + }; + scribe.commandPatches.insertOrderedList = new InsertListCommandPatch('insertOrderedList'); scribe.commandPatches.insertUnorderedList = new InsertListCommandPatch('insertUnorderedList'); }; diff --git a/src/plugins/core/patches/commands/outdent.js b/src/plugins/core/patches/commands/outdent.js index a7b1f096..d31d7966 100644 --- a/src/plugins/core/patches/commands/outdent.js +++ b/src/plugins/core/patches/commands/outdent.js @@ -8,6 +8,7 @@ define(function () { return function () { return function (scribe) { + var nodeHelpers = scribe.node; var outdentCommand = new scribe.api.CommandPatch('outdent'); outdentCommand.execute = function () { @@ -57,14 +58,15 @@ define(function () { * split the node and insert the P in the middle. */ - var nextSiblingNodes = (new scribe.api.Node(pNode)).nextAll(); + var nextSiblingNodes = nodeHelpers.nextSiblings(pNode); - if (nextSiblingNodes.length) { + if (!!nextSiblingNodes.size) { var newContainerNode = document.createElement(blockquoteNode.nodeName); - nextSiblingNodes.forEach(function (siblingNode) { - newContainerNode.appendChild(siblingNode); - }); + while (!!nextSiblingNodes.size) { + newContainerNode.appendChild(nextSiblingNodes.first()); + nextSiblingNodes = nextSiblingNodes.shift(); + } blockquoteNode.parentNode.insertBefore(newContainerNode, blockquoteNode.nextElementSibling); } diff --git a/src/plugins/core/patches/events.js b/src/plugins/core/patches/events.js index 8cf624c4..97bd9c80 100644 --- a/src/plugins/core/patches/events.js +++ b/src/plugins/core/patches/events.js @@ -4,25 +4,13 @@ define([], function () { return function () { return function (scribe) { - /** - * Chrome: If a parent node has a CSS `line-height` when we apply the - * insert(Un)OrderedList command, altering the paragraph structure by pressing - * or (merging/deleting paragraphs) sometimes - * results in the application of a line-height attribute to the - * contents of the paragraph, either onto existing elements or - * by wrapping text in a span. - * As per: http://jsbin.com/isIdoKA/4/edit?html,css,js,output - * - * FIXME: what if the user actually wants to use SPANs? This could - * cause conflicts. - */ // TODO: do we need to run this on every key press, or could we // detect when the issue may have occurred? // TODO: run in a transaction so as to record the change? how do // we know in advance whether there will be a change though? // TODO: share somehow with `InsertList` command - var element = scribe.element; + var nodeHelpers = scribe.node; if (scribe.allowsBlockElements()) { scribe.el.addEventListener('keyup', function (event) { @@ -51,32 +39,7 @@ define([], function () { scribe.transactionManager.run(function () { // Store the caret position selection.placeMarkers(); - - // We clone the childNodes into an Array so that it's - // not affected by any manipulation below when we - // iterate over it - var pElementChildNodes = Array.prototype.slice.call(containerPElement.childNodes); - pElementChildNodes.forEach(function(pElementChildNode) { - if (pElementChildNode.nodeName === 'SPAN') { - // Unwrap any SPAN that has been inserted - var spanElement = pElementChildNode; - element.unwrap(containerPElement, spanElement); - } else if (pElementChildNode.nodeType === Node.ELEMENT_NODE) { - /** - * If the paragraph contains inline elements such as - * A, B, or I, Chrome will also append an inline style for - * `line-height` on those elements, so we remove it here. - */ - pElementChildNode.style.lineHeight = null; - - // There probably wasn’t a `style` attribute before, so - // remove it if it is now empty. - if (pElementChildNode.getAttribute('style') === '') { - pElementChildNode.removeAttribute('style'); - } - } - }); - + nodeHelpers.removeChromeArtifacts(containerPElement); selection.selectMarkers(); }, true); } diff --git a/src/scribe.js b/src/scribe.js index d1cbdff6..eb54983d 100644 --- a/src/scribe.js +++ b/src/scribe.js @@ -8,10 +8,10 @@ define([ './transaction-manager', './undo-manager', './event-emitter', - './element', './node', - 'immutable/dist/immutable', - './config' + 'immutable', + './config', + './events' ], function ( plugins, commands, @@ -22,10 +22,10 @@ define([ buildTransactionManager, UndoManager, EventEmitter, - elementHelpers, nodeHelpers, Immutable, - config + config, + eventNames ) { 'use strict'; @@ -44,9 +44,6 @@ define([ this.api = new Api(this); - this.node = nodeHelpers; - this.element = elementHelpers; - this.Immutable = Immutable; var TransactionManager = buildTransactionManager(this); @@ -127,6 +124,8 @@ define([ } Scribe.prototype = Object.create(EventEmitter.prototype); + Scribe.prototype.node = nodeHelpers; + Scribe.prototype.element= Scribe.prototype.node; // For plugins // TODO: tap combinator? @@ -136,7 +135,9 @@ define([ }; Scribe.prototype.setHTML = function (html, skipFormatters) { - this._lastItem.content = html; + if (this.options.undo.enabled) { + this._lastItem.content = html; + } if (skipFormatters) { this._skipFormatters = true; @@ -170,7 +171,7 @@ define([ if (scribe.options.undo.enabled) { // Get scribe previous content, and strip markers. - var lastContentNoMarkers = scribe._lastItem.content.replace(/[^<]*?<\/em>/g, ''); + var lastContentNoMarkers = scribe._lastItem.content.replace(/]*class="scribe-marker"[^>]*>[^<]*?<\/em>/g, ''); // We only want to push the history if the content actually changed. if (scribe.getHTML() !== lastContentNoMarkers) { @@ -230,7 +231,8 @@ define([ // Because we skip the formatters, a transaction is not run, so we have to // emit this event ourselves. - this.trigger('content-changed'); + this.trigger(eventNames.legacyContentChanged); + this.trigger(eventNames.contentChanged); }; // This will most likely be moved to another object eventually @@ -246,7 +248,8 @@ define([ this.setHTML(content); - this.trigger('content-changed'); + this.trigger(eventNames.legacyContentChanged); + this.trigger(eventNames.contentChanged); }; Scribe.prototype.insertPlainText = function (plainText) { @@ -292,6 +295,10 @@ define([ = this._plainTextFormatterFactory.formatters.push(formatter); }; + Scribe.prototype.destroy = function (options) { + this.trigger(eventNames.destroy); + }; + // TODO: abstract function FormatterFactory() { this.formatters = Immutable.List(); diff --git a/src/transaction-manager.js b/src/transaction-manager.js index 1211f090..3446ace3 100644 --- a/src/transaction-manager.js +++ b/src/transaction-manager.js @@ -1,4 +1,6 @@ -define(['lodash-amd/modern/object/assign'], function (assign) { +define([ + './events' + ], function (events) { 'use strict'; @@ -7,7 +9,7 @@ define(['lodash-amd/modern/object/assign'], function (assign) { this.history = []; } - assign(TransactionManager.prototype, { + Object.assign(TransactionManager.prototype, { start: function () { this.history.push(1); }, @@ -17,7 +19,8 @@ define(['lodash-amd/modern/object/assign'], function (assign) { if (this.history.length === 0) { scribe.pushHistory(); - scribe.trigger('content-changed'); + scribe.trigger(events.legacyContentChanged); + scribe.trigger(events.contentChanged); } }, diff --git a/src/undo-manager.js b/src/undo-manager.js index 287c2585..b5862c8b 100644 --- a/src/undo-manager.js +++ b/src/undo-manager.js @@ -1,8 +1,10 @@ -define(function () { +define([ + 'immutable' +], function (Immutable) { 'use strict'; function UndoManager(limit, undoScopeHost) { - this._stack = []; + this._stack = Immutable.List(); this._limit = limit; this._fireEvent = typeof CustomEvent != 'undefined' && undoScopeHost && undoScopeHost.dispatchEvent; this._ush = undoScopeHost; @@ -18,70 +20,79 @@ define(function () { transaction.execute(); - this._stack.splice(0, this.position); - if (merge && this.length) { - this._stack[0].push(transaction); - } - else { - this._stack.unshift([transaction]); + if (this.position > 0) { + this.clearRedo(); } - this.position = 0; - if (this._limit && this._stack.length > this._limit) { - this.length = this._stack.length = this._limit; + var transactions; + if (merge && this.length) { + transactions = this._stack.first().push(transaction); + this._stack = this._stack.shift().unshift(transactions); } else { - this.length = this._stack.length; - } + transactions = Immutable.List.of(transaction); + this._stack = this._stack.unshift(transactions); + this.length++; - if (this._fireEvent) { - this._ush.dispatchEvent(new CustomEvent('DOMTransaction', {detail: {transactions: this._stack[0].slice()}, bubbles: true, cancelable: false})); + if (this._limit && this.length > this._limit) { + this.clearUndo(this._limit); + } } + + this._dispatch('DOMTransaction', transactions); }; UndoManager.prototype.undo = function () { - if (this.position < this.length) { - for (var i = this._stack[this.position].length - 1; i >= 0; i--) { - this._stack[this.position][i].undo(); - } - this.position++; + if (this.position >= this.length) { return; } - if (this._fireEvent) { - this._ush.dispatchEvent(new CustomEvent('undo', {detail: {transactions: this._stack[this.position - 1].slice()}, bubbles: true, cancelable: false})); - } + var transactions = this._stack.get(this.position); + var i = transactions.size; + while (i--) { + transactions.get(i).undo(); } + this.position++; + + this._dispatch('undo', transactions); }; UndoManager.prototype.redo = function () { - if (this.position > 0) { - for (var i = 0, n = this._stack[this.position - 1].length; i < n; i++) { - this._stack[this.position - 1][i].redo(); - } - this.position--; + if (this.position === 0) { return; } - if (this._fireEvent) { - this._ush.dispatchEvent(new CustomEvent('redo', {detail: {transactions: this._stack[this.position].slice()}, bubbles: true, cancelable: false})); - } + this.position--; + var transactions = this._stack.get(this.position); + for (var i = 0; i < transactions.size; i++) { + transactions.get(i).redo(); } + + this._dispatch('redo', transactions); }; UndoManager.prototype.item = function (index) { - if (index >= 0 && index < this.length) { - return this._stack[index].slice(); - } - return null; + return index >= 0 && index < this.length ? + this._stack.get(index).toArray() : + null; }; - UndoManager.prototype.clearUndo = function () { - this._stack.length = this.length = this.position; + UndoManager.prototype.clearUndo = function (position) { + this._stack = this._stack.take(position !== undefined ? position : this.position); + this.length = this._stack.size; }; UndoManager.prototype.clearRedo = function () { - this._stack.splice(0, this.position); + this._stack = this._stack.skip(this.position); + this.length = this._stack.size; this.position = 0; - this.length = this._stack.length; }; + UndoManager.prototype._dispatch = function(event, transactions) { + if (this._fireEvent) { + this._ush.dispatchEvent(new CustomEvent(event, { + detail: {transactions: transactions.toArray()}, + bubbles: true, + cancelable: false + })); + } + } + return UndoManager; }); - diff --git a/teamCity.sh b/teamCity.sh new file mode 100755 index 00000000..c24d794f --- /dev/null +++ b/teamCity.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +NODE_VERSION="5" + +# check if nvm is available or not +nvm_available() { + type -t nvm > /dev/null +} + +# source NVM from known locations (it's not a binary so not on the path) +source_nvm() { + if ! nvm_available; then + [ -e "/usr/local/opt/nvm/nvm.sh" ] && source /usr/local/opt/nvm/nvm.sh + fi + if ! nvm_available; then + [ -e "$HOME/.nvm/nvm.sh" ] && source $HOME/.nvm/nvm.sh + fi +} + +# do the client side build +source_nvm +nvm_available && nvm install ${NODE_VERSION} && nvm use ${NODE_VERSION} + +npm install -g bower +bower install +./setup.sh + +npm install +npm test diff --git a/test/.jshintrc b/test-old/.jshintrc similarity index 100% rename from test/.jshintrc rename to test-old/.jshintrc diff --git a/test/app/index.html b/test-old/app/index.html similarity index 87% rename from test/app/index.html rename to test-old/app/index.html index 3553d043..4391e289 100644 --- a/test/app/index.html +++ b/test-old/app/index.html @@ -12,7 +12,7 @@ 'scribe-common': '../../bower_components/scribe-common', 'lodash-amd': '../../bower_components/lodash-amd', 'html-janitor': '../../bower_components/html-janitor/html-janitor', - 'immutable': '../../bower_components/immutable' + 'immutable': '../../bower_components/immutable/dist/immutable' } }); diff --git a/test/block-mode.spec.js b/test-old/block-mode.spec.js similarity index 100% rename from test/block-mode.spec.js rename to test-old/block-mode.spec.js diff --git a/test/commands.spec.js b/test-old/commands.spec.js similarity index 80% rename from test/commands.spec.js rename to test-old/commands.spec.js index 2c3f4350..513cac9a 100644 --- a/test/commands.spec.js +++ b/test-old/commands.spec.js @@ -11,6 +11,30 @@ var initializeScribe = helpers.initializeScribe.bind(null, '../../src/scribe'); var browserBugs = helpers.browserBugs; var browserName = helpers.browserName; +var commandQueryState = function(commandName) { + return helpers.driver.executeScript(function(commandName) { + var command = window.scribe.getCommand(commandName) + return command.queryState(); + }, commandName); +}; +var commandQueryEnabled = function(commandName) { + return helpers.driver.executeScript(function(commandName) { + var command = window.scribe.getCommand(commandName) + return command.queryEnabled(); + }, commandName); +}; + +// the only way I could get selenium to properly remove focus from scribe, was to type into another field :/ +var focusOther = function() { + return helpers.driver.executeScript(function() { + var b = document.createElement('input'); + b.id = 'focusSwitchInput'; + document.body.appendChild(b); + }).then(function() { + return helpers.driver.findElement(webdriver.By.id('focusSwitchInput')).sendKeys('!'); + }); +}; + // Get new referenceS each time a new instance is created var driver; before(function () { @@ -236,6 +260,43 @@ describe('commands', function () { }); }); }); + + /* + * Query command state + */ + givenContentOf('

|1

', function() { + beforeEach(function () { + return executeCommand('insertOrderedList'); + }); + + when('the command is executed and queryState is called', function() { + it('should return true', function() { + return commandQueryState('insertOrderedList').then(function(returnValue) { + expect(returnValue).to.be.true; + }); + }); + }); + + when('the command is executed and queryEnabled is called', function() { + it('should return true for queryEnabled', function() { + return commandQueryEnabled('insertOrderedList').then(function(returnValue) { + expect(returnValue).to.be.true; + }); + }); + }); + }); + + givenContentOf('

1

', function() { + when('queryState is executed for the command', function() { + it('should return false', function() { + return focusOther().then(function() { + return commandQueryState('insertOrderedList'); + }).then(function(returnValue) { + expect(returnValue).to.be.false; + }); + }); + }); + }); }); describe('insertHTML', function () { @@ -365,6 +426,51 @@ describe('commands', function () { }); }); }); + + when('the command is executed with a value of "

123

"', function () { + beforeEach(function () { + // Focus it before-hand + scribeNode.click(); + + return executeCommand('insertHTML', '

123

'); + }); + + it.skip('should remove line-height and unnecessary SPANs (only those generated by Chrome with specific line-height)', function () { + return scribeNode.getInnerHTML().then(function (innerHTML) { + expect(innerHTML).to.have.html('

123

'); + }); + }); + }); + + when('the command is executed with a value of "

1234

"', function () { + beforeEach(function () { + // Focus it before-hand + scribeNode.click(); + + return executeCommand('insertHTML', '

1234

'); + }); + + it.skip('should remove unnecessary Chrome auto-generated line-height elements even when they are not the first items in a subtree', function () { + return scribeNode.getInnerHTML().then(function (innerHTML) { + expect(innerHTML).to.have.html('

1234

'); + }); + }); + }); + + when('the command is executed with a value of "

1

"', function () { + beforeEach(function () { + // Focus it before-hand + scribeNode.click(); + + return executeCommand('insertHTML', '

1

'); + }); + + it.skip('should not remove SPANs when they have attributes besides line-height (and are thus not Chrome auto-generated)', function () { + return scribeNode.getInnerHTML().then(function (innerHTML) { + expect(innerHTML).to.have.html('

1

'); + }); + }); + }); }); givenContentOf('

1|

', function () { diff --git a/test/formatters.spec.js b/test-old/formatters.spec.js similarity index 100% rename from test/formatters.spec.js rename to test-old/formatters.spec.js diff --git a/test/inline-elements-mode.spec.js b/test-old/inline-elements-mode.spec.js similarity index 100% rename from test/inline-elements-mode.spec.js rename to test-old/inline-elements-mode.spec.js diff --git a/test/overrides.spec.js b/test-old/overrides.spec.js similarity index 100% rename from test/overrides.spec.js rename to test-old/overrides.spec.js diff --git a/test/patches.spec.js b/test-old/patches.spec.js similarity index 100% rename from test/patches.spec.js rename to test-old/patches.spec.js diff --git a/test/runner.js b/test-old/runner.js similarity index 85% rename from test/runner.js rename to test-old/runner.js index c72e4574..e0003b1a 100644 --- a/test/runner.js +++ b/test-old/runner.js @@ -15,13 +15,15 @@ var testEnvironment = require('scribe-test-harness/environment'); var mocha = new Mocha(); +var specs = process.argv[2] || (__dirname + '/**/*.spec.js'); + /** * Wait for the connection to Sauce Labs to finish. */ mocha.timeout(15 * 1000); mocha.reporter('spec'); -testEnvironment.loadSpecifications(__dirname + '/**/*.spec.js', mocha) +testEnvironment.loadSpecifications(specs, mocha) .then(function(mocha) { createRunner(mocha); }); diff --git a/test/selection.spec.js b/test-old/selection.spec.js similarity index 100% rename from test/selection.spec.js rename to test-old/selection.spec.js diff --git a/test/undo-manager.spec.js b/test-old/undo-manager.spec.js similarity index 100% rename from test/undo-manager.spec.js rename to test-old/undo-manager.spec.js diff --git a/test-old/unit/children.spec.js b/test-old/unit/children.spec.js new file mode 100644 index 00000000..d56cdf60 --- /dev/null +++ b/test-old/unit/children.spec.js @@ -0,0 +1,19 @@ +require('node-amd-require')({ + baseUrl: __dirname, + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var children = require('../../src/node'); + +var chai = require('chai'); +var expect = chai.expect; + +describe('children API', function() { + it('should return the root node for node with no children', function() { + var fakeNode = {hasChildNodes: function() { return false; }}; + expect(children.firstDeepestChild(fakeNode)).to.equal(fakeNode); + }); +}); diff --git a/test/unit/config.spec.js b/test-old/unit/config.spec.js similarity index 100% rename from test/unit/config.spec.js rename to test-old/unit/config.spec.js diff --git a/test/unit/event-emitter.spec.js b/test-old/unit/event-emitter.spec.js similarity index 100% rename from test/unit/event-emitter.spec.js rename to test-old/unit/event-emitter.spec.js diff --git a/test-old/unit/mutations.spec.js b/test-old/unit/mutations.spec.js new file mode 100644 index 00000000..3dd046e5 --- /dev/null +++ b/test-old/unit/mutations.spec.js @@ -0,0 +1,35 @@ +require('node-amd-require')({ + baseUrl: __dirname, + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var Mutations = require('../../src/mutations'); + +var chai = require('chai'); +var expect = chai.expect; + +describe('Mutations', function() { + describe('Mutation Observer', function() { + describe('server-side', function() { + it('should provide a stub', function() { + var stubObserver = Mutations.determineMutationObserver(undefined); + + chai.assert.isFunction(stubObserver); + chai.assert.isFunction(stubObserver().observe); + }); + }); + + describe('browser', function() { + it("should use the window's MutationObserver if present", function() { + var fakeObserver = function() {}; + var returnedObserver = Mutations.determineMutationObserver({MutationObserver: fakeObserver}); + + chai.assert.strictEqual(fakeObserver, returnedObserver); + }); + + }); + }); +}); diff --git a/test-old/unit/node.spec.js b/test-old/unit/node.spec.js new file mode 100644 index 00000000..d4faed75 --- /dev/null +++ b/test-old/unit/node.spec.js @@ -0,0 +1,49 @@ +require('node-amd-require')({ + baseUrl: __dirname, + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var nodeHelpers = require('../../src/node'); + +var chai = require('chai'); + +var assert = chai.assert; + +var MockBrowser = require('mock-browser').mocks.MockBrowser; +var fakeBrowser = new MockBrowser(); +var doc = fakeBrowser.getDocument(); + +var FakeNode = { + ELEMENT_NODE: 1 +}; + +describe('Node type checking', function() { + describe('nodes with a particular class', function() { + it('provides a checking function', function() { + var checkFunction = nodeHelpers.elementHasClass(FakeNode, 'test'); + assert.isFunction(checkFunction); + }); + + it('checks that a particular class is present', function() { + var checkFunction = nodeHelpers.elementHasClass(FakeNode, 'test'); + var fakeElement = doc.createElement('div'); + fakeElement.className = 'test'; + + assert.isTrue(checkFunction(fakeElement)); + }); + + it('checks that a particular class is not present', function() { + var checkFunction = nodeHelpers.elementHasClass(FakeNode, 'test'); + var fakeElement = doc.createElement('div'); + + assert.isFalse(checkFunction(fakeElement)); + + fakeElement.className = 'fake-name'; + + assert.isFalse(checkFunction(fakeElement)); + }); + }); +}); diff --git a/test/children.spec.js b/test/children.spec.js new file mode 100644 index 00000000..7293aad2 --- /dev/null +++ b/test/children.spec.js @@ -0,0 +1,19 @@ +require('node-amd-require')({ + baseUrl: __dirname, + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var children = require('../src/node'); + +var chai = require('chai'); +var expect = chai.expect; + +describe('children API', function() { + it('should return the root node for node with no children', function() { + var fakeNode = {hasChildNodes: function() { return false; }}; + expect(children.firstDeepestChild(fakeNode)).to.equal(fakeNode); + }); +}); diff --git a/test/config.spec.js b/test/config.spec.js new file mode 100644 index 00000000..808b6d93 --- /dev/null +++ b/test/config.spec.js @@ -0,0 +1,46 @@ + + +require('node-amd-require')({ + baseUrl: __dirname, + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var config = require('../src/config'); + +var chai = require('chai'); +var expect = chai.expect; + +describe('config', function() { + it('should normalise unspecified options', function() { + expect(config.checkOptions(undefined)).to.exist; + }); + + it('should remove invalid plugins', function() { + var options = config.checkOptions({ + defaultPlugins: ['bad_plugin'], + defaultFormatters: ['bad_plugin'] + }), + dummyPluginList = ['a', 'b']; + + expect(options.defaultPlugins.length).to.be.equal(0); + expect(options.defaultFormatters.length).to.be.equal(0); + expect(['a', 'b', 'c'].filter(config.filterByPluginExists(dummyPluginList))).to.not.include('c'); + }); + + it('should respect overridden options', function() { + var checkedOptions = config.checkOptions({allowBlockElements: false}); + + expect(checkedOptions.allowBlockElements).to.be.false; + }); + + describe('defaults', function() { + it('should apply default values', function() { + var checkedOptions = config.checkOptions({}); + + expect(checkedOptions.allowBlockElements).to.be.true; + }); + }); +}); diff --git a/test/event-emitter.spec.js b/test/event-emitter.spec.js new file mode 100644 index 00000000..611c7a64 --- /dev/null +++ b/test/event-emitter.spec.js @@ -0,0 +1,75 @@ +require('node-amd-require')({ + baseUrl: __dirname, + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var chai = require('chai'); +var sinon = require('sinon'); +var EventEmitter = require('../src/event-emitter'); +var expect = chai.expect; + +var emitter; +beforeEach(function(){ + emitter = new EventEmitter(); +}); + +describe('event-emitter', function(){ + + it('should fire events in order of namespace', function(){ + + var firstHandle = sinon.spy(); + var secondHandle = sinon.spy(); + var thirdHandle = sinon.spy(); + + emitter.on('my:custom:event', firstHandle); + emitter.on('my:custom', secondHandle); + emitter.on('my', thirdHandle); + + emitter.trigger('my:custom:event'); + + expect(firstHandle.callCount).to.equal(1); + expect(secondHandle.callCount).to.equal(1); + expect(thirdHandle.callCount).to.equal(1); + + expect(firstHandle.calledBefore(secondHandle)).to.be.true; + expect(secondHandle.calledBefore(thirdHandle)).to.be.true; + + }); + + it('should remove a specific listener when off is called', function(){ + var handleOne = sinon.spy(); + var handleTwo = sinon.spy(); + + emitter.on('event', handleOne); + emitter.on('event', handleTwo); + + emitter.trigger('event'); + + emitter.off('event', handleOne); + + emitter.trigger('event'); + + expect(handleOne.callCount).to.equal(1); + expect(handleTwo.callCount).to.equal(2); + }); + + it('should remove all listeners when off is called', function(){ + var handleOne = sinon.spy(); + var handleTwo = sinon.spy(); + + emitter.on('event', handleOne); + emitter.on('event', handleTwo); + + emitter.trigger('event'); + + emitter.off('event'); + + emitter.trigger('event'); + + expect(handleOne.callCount).to.equal(1); + expect(handleTwo.callCount).to.equal(1); + }); +}); diff --git a/test/keystrokes.spec.js b/test/keystrokes.spec.js new file mode 100644 index 00000000..a850b029 --- /dev/null +++ b/test/keystrokes.spec.js @@ -0,0 +1,79 @@ +require('node-amd-require')({ + baseUrl: __dirname + "/../../src", + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var keystrokes = require('../src/keystrokes'); + + +var chai = require('chai'); + +var expect = chai.expect; + +var MockBrowser = require('mock-browser').mocks.MockBrowser; +var fakeBrowser = new MockBrowser(); +var win = fakeBrowser.getWindow(); + +function createKeyEvent(keyCode, modifiers) { + var event = new win.KeyboardEvent('keydown', { + keyCode: keyCode, + altKey: modifiers.alt || false, + metaKey: modifiers.meta || false, + shiftKey: modifiers.shift || false, + ctrlKey: modifiers.ctrl || false + }); + return event; +}; + +describe('Keystrokes', function() { + describe('Undo keystroke', function() { + it('should react to valid keystrokes', function() { + var event1 = createKeyEvent(90, { meta: true }); + var event2 = createKeyEvent(90, { ctrl: true }); + var event3 = createKeyEvent(90, { meta: true, alt: true }); + + expect(keystrokes.isUndoKeyCombination(event1), 'meta+z').to.equal(true); + expect(keystrokes.isUndoKeyCombination(event2), 'ctrl+z').to.equal(true); + expect(keystrokes.isUndoKeyCombination(event3), 'meta+alt+z').to.equal(true); + }); + + it('should ignore invalid keystrokes', function() { + var event1 = createKeyEvent(90, { meta: true, shift: true }); + var event2 = createKeyEvent(90, { ctrl: true, shift: true }); + var event3 = createKeyEvent(90, { ctrl: true, alt: true }); + var event4 = createKeyEvent(89, { meta: true }); + + expect(keystrokes.isUndoKeyCombination(event1), 'meta+shift+z').to.equal(false); + expect(keystrokes.isUndoKeyCombination(event2), 'ctrl+shift+z').to.equal(false); + expect(keystrokes.isUndoKeyCombination(event3), 'ctrl+alt+z').to.equal(false); + expect(keystrokes.isUndoKeyCombination(event4), 'meta+y').to.equal(false); + }); + }); + + describe('Redo keystroke', function() { + it('should react to valid keystrokes', function() { + var event1 = createKeyEvent(90, { meta: true, shift: true }); + var event2 = createKeyEvent(90, { ctrl: true, shift: true }); + var event3 = createKeyEvent(90, { meta: true, alt: true, shift: true }); + + expect(keystrokes.isRedoKeyCombination(event1), 'meta+shift+z').to.equal(true); + expect(keystrokes.isRedoKeyCombination(event2), 'ctrl+shift+z').to.equal(true); + expect(keystrokes.isRedoKeyCombination(event3), 'meta+alt+shift+z').to.equal(true); + }); + + it('should ignore invalid keystrokes', function() { + var event1 = createKeyEvent(90, { meta: true }); + var event2 = createKeyEvent(90, { ctrl: true, alt: true }); + var event3 = createKeyEvent(90, { ctrl: true, alt: true, shift: true }); + var event4 = createKeyEvent(89, { meta: true, shift: true }); + + expect(keystrokes.isRedoKeyCombination(event1), 'meta+z').to.equal(false); + expect(keystrokes.isRedoKeyCombination(event2), 'ctrl+alt+z').to.equal(false); + expect(keystrokes.isRedoKeyCombination(event3), 'ctrl+alt+shift+z').to.equal(false); + expect(keystrokes.isRedoKeyCombination(event4), 'meta+shift+y').to.equal(false); + }); + }); +}); diff --git a/test/mutations.spec.js b/test/mutations.spec.js new file mode 100644 index 00000000..be6dc425 --- /dev/null +++ b/test/mutations.spec.js @@ -0,0 +1,35 @@ +require('node-amd-require')({ + baseUrl: __dirname, + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var Mutations = require('../src/mutations'); + +var chai = require('chai'); +var expect = chai.expect; + +describe('Mutations', function() { + describe('Mutation Observer', function() { + describe('server-side', function() { + it('should provide a stub', function() { + var stubObserver = Mutations.determineMutationObserver(undefined); + + chai.assert.isFunction(stubObserver); + chai.assert.isFunction(stubObserver().observe); + }); + }); + + describe('browser', function() { + it("should use the window's MutationObserver if present", function() { + var fakeObserver = function() {}; + var returnedObserver = Mutations.determineMutationObserver({MutationObserver: fakeObserver}); + + chai.assert.strictEqual(fakeObserver, returnedObserver); + }); + + }); + }); +}); diff --git a/test/node.spec.js b/test/node.spec.js new file mode 100644 index 00000000..7d347d10 --- /dev/null +++ b/test/node.spec.js @@ -0,0 +1,122 @@ +require('node-amd-require')({ + baseUrl: __dirname + "/../../src", + paths: { + 'lodash-amd': '../../bower_components/lodash-amd', + 'immutable': '../../bower_components/immutable' + } +}); + +var nodeHelpers = require('../src/node'); + +var chai = require('chai'); + +var assert = chai.assert; + +var MockBrowser = require('mock-browser').mocks.MockBrowser; +var fakeBrowser = new MockBrowser(); +var doc = fakeBrowser.getDocument(); + +var FakeNode = { + ELEMENT_NODE: 1, + TEXT_NODE: 3 +}; + +describe('Node type checking', function() { + describe('nodes with a particular class', function() { + it('provides a checking function', function() { + var checkFunction = nodeHelpers.elementHasClass(FakeNode, 'test'); + assert.isFunction(checkFunction); + }); + + it('checks that a particular class is present', function() { + var checkFunction = nodeHelpers.elementHasClass(FakeNode, 'test'); + var fakeElement = doc.createElement('div'); + fakeElement.className = 'test'; + + assert.isTrue(checkFunction(fakeElement)); + }); + + it('checks that a particular class is not present', function() { + var checkFunction = nodeHelpers.elementHasClass(FakeNode, 'test'); + var fakeElement = doc.createElement('div'); + + assert.isFalse(checkFunction(fakeElement)); + + fakeElement.className = 'fake-name'; + + assert.isFalse(checkFunction(fakeElement)); + }); + }); + + describe('for whether a node has content', function() { + it('should detect a BR element', function() { + var fakeNode = {nodeName: "BR"}; + + assert.isTrue(nodeHelpers.hasContent(fakeNode)); + }); + + it('should detect a node has children', function() { + var fakeNode = {nodeName: "DIV", children: [{}]}; + + assert.isTrue(nodeHelpers.hasContent(fakeNode)); + + }); + + it('should detect a non-BR node', function() { + var fakeNode = {nodeName: "P"}; + + assert.isFalse(nodeHelpers.hasContent(fakeNode)); + }); + }); + + describe('text nodes', function() { + describe('that are whitespace-only', function() { + it('are detected', function() { + var emptyTextNode = { + nodeValue: " ", + nodeType: 3 + } + + assert.isTrue(nodeHelpers.isWhitespaceOnlyTextNode(FakeNode, emptyTextNode), "Whitespace-only node not detected correctly"); + }); + + it('are not falsely identified', function() { + var testNode = { + nodeValue: "hello world", + nodeType: 3 + } + + assert.isFalse(nodeHelpers.isWhitespaceOnlyTextNode(FakeNode, testNode), "Regular text node identified as whitespace-only"); + + }); + }); + describe('that have non-whitespace content', function() { + it('are correctly detected', function() { + var fakeNode = { + nodeValue: "hello world", + nodeType: 3 + }; + + assert.isTrue(nodeHelpers.isTextNodeWithContent(FakeNode, fakeNode), "Text node with content not detected as having content."); + }); + + it('but are actually empty', function() { + var fakeNode = { + nodeValue: "", + nodeType: 3 + }; + + assert.isFalse(nodeHelpers.isTextNodeWithContent(FakeNode, fakeNode), "Empty text node detected as having content."); + }); + + it('but only whitespace', function() { + var fakeNode = { + nodeValue: " ", + nodeType: 3 + }; + + assert.isFalse(nodeHelpers.isTextNodeWithContent(FakeNode, fakeNode), "Whitespace-only text node detected as having content."); + }); + }); + }); +});