From 6017e5676f2abe2b2477b59fb5dd7dea2a8683b5 Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Mon, 14 Aug 2017 12:12:25 -0400 Subject: [PATCH] feat(beforeToggleMarkup): Add Editor#beforeToggleMarkup hook (#571) The hook runs before editor#toggleMarkup will apply its changes. If any hook returns `false`, the changes will not be applied. This can be used to apply arbitrary user-defined rules to reject certain types of formatting changes, or e.g. validate a URL before allowing a writer to create a link to it. --- README.md | 9 ++++-- src/js/editor/editor.js | 44 +++++++++++++++++++++++++++++ src/js/models/markup.js | 15 ++++++++++ tests/unit/editor/editor-test.js | 48 ++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b65b9b451..9b49f8fc3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ![Mobiledoc Logo](https://bustle.github.io/mobiledoc-kit/demo/mobiledoc-logo-color-small.png) -Mobiledoc Kit (warning: beta) is a library for building WYSIWYG editors +Mobiledoc Kit (beta) is a framework-agnostic library for building WYSIWYG editors supporting rich content via cards. ## Libraries @@ -121,7 +121,7 @@ document's ``: * `editor.destroy()` - teardown the editor event listeners, free memory etc. * `editor.disableEditing()` - stop the user from being able to edit the current post with their cursor. Programmatic edits are still allowed. -* `editor.enableEditing()` - allow the user to make direct edits directly +* `editor.enableEditing()` - allow the user to make edits directly to a post's text. * `editor.editCard(cardSection)` - change the card to its edit mode (will change immediately if the card is already rendered, or will ensure that when the card @@ -220,6 +220,11 @@ The available lifecycle hooks are: movement or clicking in the document. * `editor.onTextInput()` - When the user adds text to the document (see [example](https://github.com/bustlelabs/mobiledoc-kit#responding-to-text-input)) * `editor.inputModeDidChange()` - The active section(s) or markup(s) at the current cursor position or selection have changed. This hook can be used with `Editor#activeMarkups` and `Editor#activeSections` to implement a custom toolbar. +* `editor.beforeToggleMarkup(({markup, range, willAdd} => {...})` - Register a + callback that will be called before `editor#toggleMarkup` is applied. If any + callback returns literal `false`, the toggling of markup will be canceled. + (Toggling markup done via the postEditor, e.g. `editor.run(postEditor => + postEditor.toggleMarkup(...))` will skip this callback. For more details on the lifecycle hooks, see the [Editor documentation](https://bustlelabs.github.io/mobiledoc-kit/demo/docs/Editor.html). diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index e66f24047..c3bb1d0aa 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -92,6 +92,8 @@ const CALLBACK_QUEUES = { * a custom toolbar. * * {@link Editor#onTextInput} -- Register callbacks when the user enters text * that matches a given string or regex. + * * {@link Editor#beforeToggleMarkup} -- Register callbacks that will be run before + * applying changes from {@link Editor#toggleMarkup} */ class Editor { /** @@ -142,6 +144,7 @@ class Editor { this._mutationHandler = new MutationHandler(this); this._editState = new EditState(this); this._callbacks = new LifecycleCallbacks(values(CALLBACK_QUEUES)); + this._beforeHooks = { toggleMarkup: [] }; DEFAULT_TEXT_INPUT_HANDLERS.forEach(handler => this.onTextInput(handler)); @@ -848,12 +851,34 @@ class Editor { }); } + /** + * @callback editorBeforeCallback + * @param { Object } details + * @param { Markup } details.markup + * @param { Range } details.range + * @param { boolean } details.willAdd Whether the markup will be applied + */ + + /** + * Register a callback that will be run before {@link Editor#toggleMarkup} is applied. + * If any callback returns literal `false`, the toggling of markup will be canceled. + * Note this only applies to calling `editor#toggleMarkup`. Using `editor.run` and + * modifying markup with the `postEditor` will skip any `beforeToggleMarkup` callbacks. + * @param {editorBeforeCallback} + */ + beforeToggleMarkup(callback) { + this._beforeHooks.toggleMarkup.push(callback); + } + /** * Toggles the given markup at the editor's current {@link Range}. * If the range is collapsed this changes the editor's state so that the * next characters typed will be affected. If there is text selected * (aka a non-collapsed range), the selections' markup will be toggled. * If the editor is not focused and has no active range, nothing happens. + * Hooks added using #beforeToggleMarkup will be run before toggling, + * and if any of them returns literal false, toggling the markup will be canceled + * and no change will be applied. * @param {String} markup E.g. "b", "em", "a" * @param {Object} [attributes={}] E.g. {href: "http://bustle.com"} * @public @@ -862,6 +887,10 @@ class Editor { toggleMarkup(markup, attributes={}) { markup = this.builder.createMarkup(markup, attributes); let { range } = this; + let willAdd = !this.detectMarkupInRange(range, markup.tagName); + let shouldCancel = this._runBeforeHooks('toggleMarkup', {markup, range, willAdd}); + if (shouldCancel) { return; } + if (range.isCollapsed) { this._editState.toggleMarkupState(markup); this._inputModeDidChange(); @@ -1093,6 +1122,21 @@ class Editor { } this._callbacks.runCallbacks(...args); } + + /** + * Runs each callback for the given hookName. + * Only the hookName 'toggleMarkup' is currently supported + * @return {Boolean} shouldCancel Whether the action in `hookName` should be canceled + * @private + */ + _runBeforeHooks(hookName, ...args) { + let hooks = this._beforeHooks[hookName] || []; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i](...args) === false) { + return true; + } + } + } } export default Editor; diff --git a/src/js/models/markup.js b/src/js/models/markup.js index c8a5091c0..074d8862c 100644 --- a/src/js/models/markup.js +++ b/src/js/models/markup.js @@ -21,6 +21,12 @@ export const VALID_ATTRIBUTES = [ 'rel' ]; +/** + * A Markup is similar with an inline HTML tag that might be added to + * text to modify its meaning and/or display. Examples of types of markup + * that could be added are bold ('b'), italic ('i'), strikethrough ('s'), and `a` tags (links). + * @property {String} tagName + */ class Markup { /* * @param {Object} attributes key-values @@ -38,6 +44,11 @@ class Markup { VALID_MARKUP_TAGNAMES.indexOf(this.tagName) !== -1); } + /** + * Whether text in the forward direction of the cursor (i.e. to the right in ltr text) + * should be considered to have this markup applied to it. + * @private + */ isForwardInclusive() { return this.tagName === normalizeTagName("a") ? false : true; } @@ -50,6 +61,10 @@ class Markup { return this.tagName === normalizeTagName(tagName); } + /** + * Returns the attribute value + * @param {String} name, e.g. "href" + */ getAttribute(name) { return this.attributes[name]; } diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index 972e9c587..3cde5ee67 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -752,3 +752,51 @@ test('#toggleMarkup adds A tag with attributes', function(assert) { assert.hasElement('#editor a:contains(link)'); assert.hasElement('#editor a[href="google.com"]:contains(link)'); }); + +test('#toggleMarkup calls #beforeToggleMarkup hooks', function(assert) { + assert.expect(5*3 + 2); + + let callbackCount = 0; + editor = Helpers.mobiledoc.renderInto(editorElement, + ({post, markupSection, marker, markup}) => { + return post([markupSection('p', [marker('^link$')])]); + }); + Helpers.dom.selectText(editor, 'link'); + let callback = ({markup, range, willAdd}) => { + assert.ok(true, 'calls #beforeToggleMarkup'); + assert.equal(markup.tagName, 'a', 'passes markup'); + assert.equal(markup.getAttribute('href'), 'google.com', + 'passes markup with attrs'); + assert.ok(!!range, 'passes a range'); + assert.ok(willAdd, 'correct value for willAdd'); + callbackCount++; + }; + + // 3 times + editor.beforeToggleMarkup(callback); + editor.beforeToggleMarkup(callback); + editor.beforeToggleMarkup(callback); + + editor.toggleMarkup('a', {href: 'google.com'}); + assert.equal(callbackCount, 3, 'calls once for each callback'); + assert.hasElement('#editor a[href="google.com"]:contains(link)', + 'adds link'); +}); + +test('#toggleMarkup is canceled if #beforeToggleMarkup hook returns false', function(assert) { + assert.expect(2); + editor = Helpers.mobiledoc.renderInto(editorElement, + ({post, markupSection, marker, markup}) => { + return post([markupSection('p', [marker('^link$')])]); + }); + Helpers.dom.selectText(editor, 'link'); + let callback = ({markup, range, willAdd}) => { + assert.ok(true, 'calls #beforeToggleMarkup'); + return false; + }; + + editor.beforeToggleMarkup(callback); + + editor.toggleMarkup('a', {href: 'google.com'}); + assert.hasNoElement('#editor a', 'not adds link'); +});