Skip to content

Commit

Permalink
feat(beforeToggleMarkup): Add Editor#beforeToggleMarkup hook (#571)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bantic committed Aug 14, 2017
1 parent de70839 commit 6017e56
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 2 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,7 +121,7 @@ document's `<head>`:
* `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
Expand Down Expand Up @@ -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).

Expand Down
44 changes: 44 additions & 0 deletions src/js/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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;
15 changes: 15 additions & 0 deletions src/js/models/markup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -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];
}
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/editor/editor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

0 comments on commit 6017e56

Please sign in to comment.