From 33353579944c3e698a178ab47f4c21394a14d624 Mon Sep 17 00:00:00 2001 From: Josh French Date: Thu, 8 Sep 2016 19:23:05 -0700 Subject: [PATCH] feat(UI): Export toggleLink (#491) * WIP: expose `toggleLink` as public method * Object.keys flunks outside of browser env * Unit tests for UI.toggleLink * Export UI for reuse by editor kits * DRY up renderIntoAndFocusTail * Document UI.toggleLink --- src/js/editor/editor.js | 4 -- src/js/editor/key-commands.js | 27 +------- src/js/editor/ui.js | 69 ++++++++++++++++++++ src/js/index.js | 2 + tests/acceptance/editor-key-commands-test.js | 67 +++++++++++-------- tests/acceptance/editor-undo-redo-test.js | 30 +++------ tests/helpers/mobiledoc.js | 14 +++- tests/unit/editor/ui-test.js | 45 +++++++++++++ 8 files changed, 178 insertions(+), 80 deletions(-) create mode 100644 src/js/editor/ui.js create mode 100644 tests/unit/editor/ui-test.js diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 5e7a7d290..fcb5d21d8 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -344,10 +344,6 @@ class Editor { }); } - showPrompt(message, defaultValue, callback) { - callback(window.prompt(message, defaultValue)); - } - /** * Notify the editor that the post did change, and run associated * callbacks. diff --git a/src/js/editor/key-commands.js b/src/js/editor/key-commands.js index e43053857..334ca3994 100644 --- a/src/js/editor/key-commands.js +++ b/src/js/editor/key-commands.js @@ -3,6 +3,7 @@ import { MODIFIERS, SPECIAL_KEYS } from '../utils/key'; import { filter, reduce } from '../utils/array-utils'; import assert from '../utils/assert'; import Browser from '../utils/browser'; +import { toggleLink } from './ui'; function selectAll(editor) { let { post } = editor; @@ -37,32 +38,6 @@ function deleteToEndOfSection(editor) { }); } -function toggleLink(editor) { - if (editor.range.isCollapsed) { - return; - } - - let selectedText = editor.cursor.selectedText(); - let defaultUrl = ''; - if (selectedText.indexOf('http') !== -1) { defaultUrl = selectedText; } - - let {range} = editor; - let hasLink = editor.detectMarkupInRange(range, 'a'); - - if (hasLink) { - editor.run(postEditor => postEditor.toggleMarkup('a')); - } else { - editor.showPrompt('Enter a URL', defaultUrl, url => { - if (!url) { return; } - - editor.run(postEditor => { - let markup = postEditor.builder.createMarkup('a', {href: url}); - postEditor.toggleMarkup(markup); - }); - }); - } -} - export const DEFAULT_KEY_COMMANDS = [{ str: 'META+B', run(editor) { diff --git a/src/js/editor/ui.js b/src/js/editor/ui.js new file mode 100644 index 000000000..70579b344 --- /dev/null +++ b/src/js/editor/ui.js @@ -0,0 +1,69 @@ +/** + * @module UI + */ + +/** + * @callback promptCallback + * @param {String} url The URL to pass back to the editor for linking + * to the selected text. + */ + +/** + * @callback showPrompt + * @param {String} message The text of the prompt. + * @param {String} defaultValue The initial URL to display in the prompt. + * @param {module:UI~promptCallback} callback Once your handler has accepted a URL, + * it should pass it to `callback` so that the editor may link the + * selected text. + */ + +/** + * Exposes the core behavior for linking and unlinking text, and allows for + * customization of the URL input handler. + * @param {Editor} editor An editor instance to operate on. If a range is selected, + * either prompt for a URL and add a link or un-link the + * currently linked text. + * @param {module:UI~showPrompt} [showPrompt] An optional custom input handler. Defaults + * to using `window.prompt`. + * @example + * let myPrompt = (message, defaultURL, promptCallback) => { + * let url = window.prompt("Overriding the defaults", "http://placekitten.com"); + * promptCallback(url); + * }; + * + * editor.registerKeyCommand({ + * str: "META+K", + * run(editor) { + * toggleLink(editor, myPrompt); + * } + * }); + * @public + */ + +let defaultShowPrompt = (message, defaultValue, callback) => callback(window.prompt(message, defaultValue)); + +export function toggleLink(editor, showPrompt=defaultShowPrompt) { + if (editor.range.isCollapsed) { + return; + } + + let selectedText = editor.cursor.selectedText(); + let defaultUrl = ''; + if (selectedText.indexOf('http') !== -1) { defaultUrl = selectedText; } + + let {range} = editor; + let hasLink = editor.detectMarkupInRange(range, 'a'); + + if (hasLink) { + editor.run(postEditor => postEditor.toggleMarkup('a')); + } else { + showPrompt('Enter a URL', defaultUrl, url => { + if (!url) { return; } + + editor.run(postEditor => { + let markup = postEditor.builder.createMarkup('a', {href: url}); + postEditor.toggleMarkup(markup); + }); + }); + } +} diff --git a/src/js/index.js b/src/js/index.js index 388a735ca..b1cab8baa 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -1,4 +1,5 @@ import Editor from './editor/editor'; +import UI from './editor/ui'; import ImageCard from './cards/image'; import Range from './utils/cursor/range'; import Position from './utils/cursor/position'; @@ -7,6 +8,7 @@ import VERSION from './version'; const Mobiledoc = { Editor, + UI, ImageCard, Range, Position, diff --git a/tests/acceptance/editor-key-commands-test.js b/tests/acceptance/editor-key-commands-test.js index 3c8550d9f..8ebd807de 100644 --- a/tests/acceptance/editor-key-commands-test.js +++ b/tests/acceptance/editor-key-commands-test.js @@ -1,21 +1,18 @@ import { MODIFIERS } from 'mobiledoc-kit/utils/key'; import Keycodes from 'mobiledoc-kit/utils/keycodes'; import Helpers from '../test-helpers'; -import Range from 'mobiledoc-kit/utils/cursor/range'; import Browser from 'mobiledoc-kit/utils/browser'; +import { toggleLink } from 'mobiledoc-kit/editor/ui'; const { module, test, skip } = Helpers; let editor, editorElement; -// In Firefox, if the window isn't active (which can happen when running tests -// at SauceLabs), the editor element won't have the selection. This helper method -// ensures that it has a cursor selection. -// See https://github.com/bustlelabs/mobiledoc-kit/issues/388 -function renderIntoAndFocusTail(treeFn, options={}) { - let editor = Helpers.mobiledoc.renderInto(editorElement, treeFn, options); - editor.selectRange(new Range(editor.post.tailPosition())); - return editor; +function labelForModifier(key) { + switch (key) { + case MODIFIERS.META: return 'META'; + case MODIFIERS.CTRL: return 'CTRL'; + } } module('Acceptance: Editor: Key Commands', { @@ -38,7 +35,7 @@ function testStatefulCommand({modifierName, key, command, markupName}) { let modifier = MODIFIERS[modifierName]; let modifierKeyCode = Keycodes[modifierName]; let initialText = 'something'; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker(initialText)]) ])); @@ -64,7 +61,7 @@ function testStatefulCommand({modifierName, key, command, markupName}) { let modifierKeyCode = Keycodes[modifierName]; let initialText = 'something'; - editor =renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker(initialText)]) ])); @@ -156,7 +153,7 @@ testStatefulCommand({ if (Browser.isMac()) { test(`[Mac] ctrl-k clears to the end of a line`, (assert) => { let initialText = 'something'; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker(initialText)]) ])); @@ -181,7 +178,7 @@ if (Browser.isMac()) { test(`[Mac] ctrl-k clears selected text`, (assert) => { let initialText = 'something'; - editor = renderIntoAndFocusTail( ({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker(initialText)]) ])); @@ -209,16 +206,21 @@ let toggleLinkTest = (assert, modifier) => { assert.expect(3); let url = 'http://bustle.com'; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker('something')]) ])); - assert.ok(editor.hasCursor(), 'has cursor'); + editor.registerKeyCommand({ + str: `${labelForModifier(modifier)}+K`, + run(editor) { + toggleLink(editor, (prompt, defaultUrl, callback) => { + assert.ok(true, 'calls showPrompt'); + callback(url); + }); + } + }); - editor.showPrompt = (prompt, defaultUrl, callback) => { - assert.ok(true, 'calls showPrompt'); - callback(url); - }; + assert.ok(editor.hasCursor(), 'has cursor'); Helpers.dom.selectText(editor ,'something', editorElement); Helpers.dom.triggerKeyCommand(editor, 'K', modifier); @@ -230,15 +232,22 @@ let toggleLinkUnlinkTest = (assert, modifier) => { assert.expect(4); let url = 'http://bustle.com'; - editor = renderIntoAndFocusTail(({post, markupSection, marker, markup}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker, markup}) => post([ markupSection('p', [marker('something', [markup('a', {href:url})])]) ])); + editor.registerKeyCommand({ + str: `${labelForModifier(modifier)}+K`, + run(editor) { + toggleLink(editor, (prompt, defaultUrl, callback) => { + assert.ok(false, 'should not call showPrompt'); + callback(url); + }); + } + }); + assert.ok(editor.hasCursor(), 'has cursor'); - editor.showPrompt = () => { - assert.ok(false, 'should not call showPrompt'); - }; assert.hasElement(`#editor a[href="${url}"]:contains(something)`, 'precond -- has link'); @@ -288,7 +297,7 @@ toggleTests.forEach(({precondition, msg, testFn, modifier}) => { }); test('new key commands can be registered', (assert) => { - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker('something')]) ])); @@ -310,7 +319,7 @@ test('new key commands can be registered', (assert) => { }); test('new key commands can be registered without modifiers', (assert) => { - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker('something')]) ])); @@ -336,7 +345,7 @@ test('new key commands can be registered without modifiers', (assert) => { }); test('duplicate key commands can be registered with the last registered winning', (assert) => { - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker('something')]) ])); @@ -360,7 +369,7 @@ test('duplicate key commands can be registered with the last registered winning' }); test('returning false from key command causes next match to run', (assert) => { - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker('something')]) ])); @@ -387,7 +396,7 @@ test('returning false from key command causes next match to run', (assert) => { }); test('key commands can override built-in functionality', (assert) => { - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker('something')]) ])); @@ -410,7 +419,7 @@ test('key commands can override built-in functionality', (assert) => { }); test('returning false from key command still runs built-in functionality', (assert) => { - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ markupSection('p', [marker('something')]) ])); diff --git a/tests/acceptance/editor-undo-redo-test.js b/tests/acceptance/editor-undo-redo-test.js index 53e9d64a0..a79c40eec 100644 --- a/tests/acceptance/editor-undo-redo-test.js +++ b/tests/acceptance/editor-undo-redo-test.js @@ -13,16 +13,6 @@ function redo(editor) { Helpers.dom.triggerKeyCommand(editor, 'Z', [MODIFIERS.META, MODIFIERS.SHIFT]); } -// In Firefox, if the window isn't active (which can happen when running tests -// at SauceLabs), the editor element won't have the selection. This helper method -// ensures that it has a cursor selection. -// See https://github.com/bustlelabs/mobiledoc-kit/issues/388 -function renderIntoAndFocusTail(treeFn, options={}) { - let editor = Helpers.mobiledoc.renderInto(editorElement, treeFn, options); - editor.selectRange(editor.post.tailPosition()); - return editor; -} - module('Acceptance: Editor: Undo/Redo', { beforeEach() { editorElement = $('#editor')[0]; @@ -38,7 +28,7 @@ module('Acceptance: Editor: Undo/Redo', { test('undo/redo the insertion of a character', (assert) => { let done = assert.async(); let expectedBeforeUndo, expectedAfterUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => { expectedBeforeUndo = post([markupSection('p', [marker('abcD')])]); expectedAfterUndo = post([markupSection('p', [marker('abc')])]); return expectedAfterUndo; @@ -75,7 +65,7 @@ test('undo/redo the insertion of a character', (assert) => { test('undo/redo the insertion of multiple characters', (assert) => { let done = assert.async(); let beforeUndo, afterUndo1, afterUndo2; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => { beforeUndo = post([markupSection('p', [marker('abcDE')])]); afterUndo1 = post([markupSection('p', [marker('abcD')])]); afterUndo2 = post([markupSection('p', [marker('abc')])]); @@ -111,7 +101,7 @@ test('undo/redo the insertion of multiple characters', (assert) => { test('undo the deletion of a character', (assert) => { let expectedBeforeUndo, expectedAfterUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => { expectedBeforeUndo = post([markupSection('p', [marker('abc')])]); expectedAfterUndo = post([markupSection('p', [marker('abcD')])]); return expectedAfterUndo; @@ -139,7 +129,7 @@ test('undo the deletion of a character', (assert) => { test('undo the deletion of a range', (assert) => { let expectedBeforeUndo, expectedAfterUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => { expectedBeforeUndo = post([markupSection('p', [marker('ad')])]); expectedAfterUndo = post([markupSection('p', [marker('abcd')])]); return expectedAfterUndo; @@ -171,7 +161,7 @@ test('undo the deletion of a range', (assert) => { test('undo insertion of character to a list item', (assert) => { let done = assert.async(); let expectedBeforeUndo, expectedAfterUndo; - editor = renderIntoAndFocusTail(({post, listSection, listItem, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, listSection, listItem, marker}) => { expectedBeforeUndo = post([ listSection('ul', [listItem([marker('abcD')])]) ]); @@ -214,7 +204,7 @@ test('undo stack length can be configured (depth 1)', (assert) => { let editorOptions = { undoDepth: 1 }; let beforeUndo, afterUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => { beforeUndo = post([markupSection('p', [marker('abcDE')])]); afterUndo = post([markupSection('p', [marker('abcD')])]); return post([markupSection('p', [marker('abc')])]); @@ -250,7 +240,7 @@ test('undo stack length can be configured (depth 0)', (assert) => { let editorOptions = { undoDepth: 0 }; let beforeUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => { beforeUndo = post([markupSection('p', [marker('abcDE')])]); return post([markupSection('p', [marker('abc')])]); }, editorOptions); @@ -297,7 +287,7 @@ test('take and undo a snapshot based on drag/dropping of text', (assert) => { let done = assert.async(); let text = 'abc'; let beforeUndo, afterUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => { beforeUndo = post([markupSection('p', [marker(text)])]); afterUndo = post([markupSection('p', [marker('a')])]); return afterUndo; @@ -326,7 +316,7 @@ test('take and undo a snapshot when adding a card', (assert) => { }; let beforeUndo, afterUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker, cardSection}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker, cardSection}) => { beforeUndo = post([ markupSection('p', [marker(text)]), cardSection('my-card', {}) @@ -358,7 +348,7 @@ test('take and undo a snapshot when removing an atom', (assert) => { }; let beforeUndo, afterUndo; - editor = renderIntoAndFocusTail(({post, markupSection, marker, atom}) => { + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker, atom}) => { beforeUndo = post([markupSection('p', [marker(text)])]); afterUndo = post([ markupSection('p', [marker(text), atom('my-atom', 'content', {})]), diff --git a/tests/helpers/mobiledoc.js b/tests/helpers/mobiledoc.js index ae1440726..12b7aaf99 100644 --- a/tests/helpers/mobiledoc.js +++ b/tests/helpers/mobiledoc.js @@ -3,6 +3,7 @@ import mobiledocRenderers from 'mobiledoc-kit/renderers/mobiledoc'; import MobiledocRenderer_0_2, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_2 } from 'mobiledoc-kit/renderers/mobiledoc/0-2'; import MobiledocRenderer_0_3, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3 } from 'mobiledoc-kit/renderers/mobiledoc/0-3'; import Editor from 'mobiledoc-kit/editor/editor'; +import Range from 'mobiledoc-kit/utils/cursor/range'; import { mergeWithOptions } from 'mobiledoc-kit/utils/merge'; /* @@ -47,8 +48,19 @@ function renderInto(element, treeFn, editorOptions={}) { return editor; } +// In Firefox, if the window isn't active (which can happen when running tests +// at SauceLabs), the editor element won't have the selection. This helper method +// ensures that it has a cursor selection. +// See https://github.com/bustlelabs/mobiledoc-kit/issues/388 +function renderIntoAndFocusTail(editorElement, treeFn, options={}) { + let editor = renderInto(editorElement, treeFn, options); + editor.selectRange(new Range(editor.post.tailPosition())); + return editor; +} + export default { build, renderInto, - renderPostInto + renderPostInto, + renderIntoAndFocusTail }; diff --git a/tests/unit/editor/ui-test.js b/tests/unit/editor/ui-test.js new file mode 100644 index 000000000..70505e518 --- /dev/null +++ b/tests/unit/editor/ui-test.js @@ -0,0 +1,45 @@ +import { toggleLink } from 'mobiledoc-kit/editor/ui'; +import Helpers from '../../test-helpers'; + +const { module, test } = Helpers; + +let editor, editorElement; + +module('Unit: UI', { + beforeEach() { + editorElement = $('#editor')[0]; + }, + afterEach() { + if (editor) { + editor.destroy(); + editor = null; + } + } +}); + +test('toggleLink calls the default window prompt', (assert) => { + assert.expect(1); + window.prompt = () => assert.ok(true, 'window.prompt called'); + + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ + markupSection('p', [marker('something')]) + ])); + + Helpers.dom.selectText(editor ,'something', editorElement); + + toggleLink(editor); +}); + +test('toggleLink accepts a custom prompt function', (assert) => { + assert.expect(1); + + let prompt = () => assert.ok(true, 'custom prompt called'); + + editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => post([ + markupSection('p', [marker('something')]) + ])); + + Helpers.dom.selectText(editor ,'something', editorElement); + + toggleLink(editor, prompt); +});