diff --git a/rollup.config.js b/rollup.config.js index 45624ccdb..d2057bff0 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -46,7 +46,9 @@ export default args => [ input: 'tests/index.js', plugins: [ ...commonPlugins(), - globImport(), + globImport({ + format: 'import' + }), copy({ targets: [ { src: 'dist/mobiledoc.js', dest: 'assets/demo' }, diff --git a/src/js/editor/editor.ts b/src/js/editor/editor.ts index 22608c5f2..82b59a0d3 100644 --- a/src/js/editor/editor.ts +++ b/src/js/editor/editor.ts @@ -43,13 +43,14 @@ import Post from '../models/post' import { Mobiledoc } from '../renderers/mobiledoc' import { SectionParserPlugin } from '../parsers/section' import { CardData, CardRenderHook } from '../models/card-node' -import { AtomData } from '../models/atom-node' +import { AtomData } from '../models/atoms/atom-node' import { Option, Maybe, Dict } from '../utils/types' import Markup from '../models/markup' import View from '../views/view' -import Atom, { AtomPayload } from '../models/atom' +import Atom, { AtomPayload } from '../models/atoms/atom' import Section, { isNested } from '../models/_section' import { TextInputHandlerListener } from './text-input-handler' +import ElementAtom from 'mobiledoc-kit/models/atoms/element-atom' // This export may later be deprecated, but re-export it from the renderer here // for consumers that may depend on it. @@ -76,6 +77,14 @@ export interface EditorOptions { nodeType?: number } +const SoftBreakAtom: AtomData = { + name: '-soft-break', + type: 'dom', + render() { + return document.createElement('br') + } +} + const defaults: EditorOptions = { placeholder: 'Write here...', spellcheck: true, @@ -84,7 +93,7 @@ const defaults: EditorOptions = { undoDepth: 5, undoBlockTimeout: 5000, // ms for an undo event cards: [], - atoms: [], + atoms: [SoftBreakAtom], cardOptions: {}, unknownCardHandler: ({ env }) => { throw new MobiledocError(`Unknown card encountered: ${env.name}`) @@ -1189,6 +1198,27 @@ export default class Editor implements EditorOptions { }) } + insertTextWithMarkup(text: string, markups: Markup[] = []) { + if (!this.hasCursor()) { + return + } + if (this.post.isBlank) { + this._insertEmptyMarkupSectionAtCursor() + } + let { + range, + range: { head: position }, + } = this + + this.run(postEditor => { + if (!range.isCollapsed) { + position = postEditor.deleteRange(range) + } + + postEditor.insertTextWithMarkup(position, text, markups) + }) + } + /** * Inserts an atom at the current cursor position. If the editor has * no current cursor position, nothing will be inserted. If the editor's @@ -1223,6 +1253,40 @@ export default class Editor implements EditorOptions { return atom! } + /** + * Inserts an atom at the current cursor position. If the editor has + * no current cursor position, nothing will be inserted. If the editor's + * range is not collapsed, it will be deleted before insertion. + * @param {String} atomName + * @param {String} [atomText=''] + * @param {Object} [atomPayload={}] + * @return {Atom} The inserted atom. + * @public + */ + insertElementAtom(tagName: string): Maybe { + if (!this.hasCursor()) { + return + } + + if (this.post.isBlank) { + this._insertEmptyMarkupSectionAtCursor() + } + + let atom: ElementAtom + let { range } = this + this.run(postEditor => { + let position = range.head + + atom = postEditor.builder.createElementAtom(tagName) + if (!range.isCollapsed) { + position = postEditor.deleteRange(range) + } + + postEditor.insertMarkers(position, [atom]) + }) + return atom! + } + /** * Inserts a card at the section after the current cursor position. If the editor has * no current cursor position, nothing will be inserted. If the editor's diff --git a/src/js/editor/event-manager.ts b/src/js/editor/event-manager.ts index 70c28c01b..cf3cce27b 100644 --- a/src/js/editor/event-manager.ts +++ b/src/js/editor/event-manager.ts @@ -250,6 +250,11 @@ export default class EventManager { event.preventDefault() break } + case key.isShiftEnter(): + event.preventDefault() + // editor.insertTextWithMarkup(' ', [editor.builder.createMarkup('br')]) + editor.insertElementAtom('br') + break case key.isEnter(): this._textInputHandler.handleNewLine() editor.handleNewline(event) diff --git a/src/js/models/atom-node.ts b/src/js/models/atoms/atom-node.ts similarity index 86% rename from src/js/models/atom-node.ts rename to src/js/models/atoms/atom-node.ts index 7d94297c5..67298361d 100644 --- a/src/js/models/atom-node.ts +++ b/src/js/models/atoms/atom-node.ts @@ -1,6 +1,6 @@ -import Atom from './atom' -import assert from '../utils/assert' -import { JsonData, Dict, Maybe } from '../utils/types' +import CustomAtom from './custom-atom' +import assert from '../../utils/assert' +import { JsonData, Dict, Maybe } from '../../utils/types' export type AtomOptions = Dict @@ -12,7 +12,7 @@ export interface AtomRenderOptions { payload: JsonData } -export type AtomRenderHook = (options: AtomRenderOptions) => Maybe +export type AtomRenderHook = (options: AtomRenderOptions) => Maybe | void export type AtomData = { name: string @@ -23,14 +23,14 @@ export type AtomData = { export default class AtomNode { editor: any atom: AtomData - model: Atom + model: CustomAtom element: Element atomOptions: AtomOptions _teardownCallback: TeardownCallback | null = null _rendered: Maybe - constructor(editor: any, atom: AtomData, model: Atom, element: Element, atomOptions: AtomOptions) { + constructor(editor: any, atom: AtomData, model: CustomAtom, element: Element, atomOptions: AtomOptions) { this.editor = editor this.atom = atom this.model = model @@ -46,7 +46,7 @@ export default class AtomNode { model: { value, payload }, } = this // cache initial render - this._rendered = this.atom.render({ options, env, value, payload }) + this._rendered = this.atom.render({ options, env, value, payload }) || null } this._validateAndAppendRenderResult(this._rendered!) diff --git a/src/js/models/atoms/atom-type.ts b/src/js/models/atoms/atom-type.ts new file mode 100644 index 000000000..f9a6ea896 --- /dev/null +++ b/src/js/models/atoms/atom-type.ts @@ -0,0 +1,6 @@ +const enum AtomType { + CUSTOM = 1, + ELEMENT = 2 +} + +export default AtomType diff --git a/src/js/models/atoms/atom.ts b/src/js/models/atoms/atom.ts new file mode 100644 index 000000000..e569b78f5 --- /dev/null +++ b/src/js/models/atoms/atom.ts @@ -0,0 +1,14 @@ +import { Type } from '../types' +import ElementAtom from './element-atom' +import CustomAtom from './custom-atom' +import { PostNode } from '../post-node-builder' + +export { default as AtomType } from './atom-type' +export default CustomAtom +export { AtomPayload } from './custom-atom' + +export type SomeAtom = ElementAtom | CustomAtom + +export function isAtom(postNode: PostNode): postNode is SomeAtom { + return postNode.type === Type.ATOM +} diff --git a/src/js/models/atom.ts b/src/js/models/atoms/custom-atom.ts similarity index 86% rename from src/js/models/atom.ts rename to src/js/models/atoms/custom-atom.ts index 8c18ed768..e017c3193 100644 --- a/src/js/models/atom.ts +++ b/src/js/models/atoms/custom-atom.ts @@ -1,8 +1,9 @@ -import { Type } from './types' -import Markuperable from '../utils/markuperable' -import assert from '../utils/assert' -import Markup from './markup' -import PostNodeBuilder, { PostNode } from './post-node-builder' +import { Type } from '../types' +import Markuperable from '../../utils/markuperable' +import assert from '../../utils/assert' +import Markup from '../markup' +import PostNodeBuilder from '../post-node-builder' +import AtomType from './atom-type' const ATOM_LENGTH = 1 @@ -10,6 +11,7 @@ export type AtomPayload = {} export default class Atom extends Markuperable { type: Type = Type.ATOM + atomType = AtomType.CUSTOM isAtom = true name: string @@ -95,7 +97,3 @@ export default class Atom extends Markuperable { return [pre, post] } } - -export function isAtom(postNode: PostNode): postNode is Atom { - return postNode.type === Type.ATOM -} diff --git a/src/js/models/atoms/element-atom.ts b/src/js/models/atoms/element-atom.ts new file mode 100644 index 000000000..dabfbc7a8 --- /dev/null +++ b/src/js/models/atoms/element-atom.ts @@ -0,0 +1,92 @@ +import { Type } from '../types' +import Markuperable from '../../utils/markuperable' +import assert from '../../utils/assert' +import Markup from '../markup' +import PostNodeBuilder from '../post-node-builder' +import AtomType from './atom-type' + +const ATOM_LENGTH = 1 + +export default class ElementAtom extends Markuperable { + type: Type = Type.ATOM + atomType = AtomType.ELEMENT + + isMarker = false + isAtom = true + + name: string + value: string = '' + text: string = '' + + markups: Markup[] + builder!: PostNodeBuilder + + constructor(tagName: string, markups: Markup[] = []) { + super() + this.name = tagName + + this.markups = [] + markups.forEach(m => this.addMarkup(m)) + } + + clone() { + let clonedMarkups = this.markups.slice() + return this.builder.createElementAtom(this.name, clonedMarkups) + } + + get isBlank() { + return false + } + + get length() { + return ATOM_LENGTH + } + + canJoin(/* other */) { + return false + } + + textUntil(/* offset */) { + return '' + } + + split(offset = 0, endOffset = offset) { + let markers: Markuperable[] = [] + + if (endOffset === 0) { + markers.push(this.builder.createMarker('', this.markups.slice())) + } + + markers.push(this.clone()) + + if (offset === ATOM_LENGTH) { + markers.push(this.builder.createMarker('', this.markups.slice())) + } + + return markers + } + + splitAtOffset(offset: number): [Markuperable, Markuperable] { + assert('Cannot split a marker at an offset > its length', offset <= this.length) + + let { builder } = this + let clone = this.clone() + let blankMarker = builder.createMarker('') + let pre: Markuperable, post: Markuperable + + if (offset === 0) { + ;[pre, post] = [blankMarker, clone] + } else if (offset === ATOM_LENGTH) { + ;[pre, post] = [clone, blankMarker] + } else { + assert(`Invalid offset given to Atom#splitAtOffset: "${offset}"`, false) + } + + this.markups.forEach(markup => { + pre.addMarkup(markup) + post.addMarkup(markup) + }) + + return [pre, post] + } +} diff --git a/src/js/models/markup.ts b/src/js/models/markup.ts index 734731c57..81c97e84c 100644 --- a/src/js/models/markup.ts +++ b/src/js/models/markup.ts @@ -16,6 +16,7 @@ export const VALID_MARKUP_TAGNAMES = [ 'sub', // subscript 'sup', // superscript 'u', + 'br' ].map(normalizeTagName) export const VALID_ATTRIBUTES = ['href', 'rel'] diff --git a/src/js/models/post-node-builder.ts b/src/js/models/post-node-builder.ts index af52fe73b..c242b29df 100644 --- a/src/js/models/post-node-builder.ts +++ b/src/js/models/post-node-builder.ts @@ -1,4 +1,4 @@ -import Atom, { AtomPayload } from './atom' +import Atom, { AtomPayload } from './atoms/custom-atom' import Post from './post' import MarkupSection from './markup-section' import ListSection from './list-section' @@ -19,6 +19,7 @@ import Markuperable from '../utils/markuperable' import Section from './_section' import { Cloneable } from './_cloneable' import { Dict } from '../utils/types' +import ElementAtom from './atoms/element-atom' function cacheKey(tagName: string, attributes: Dict) { return `${normalizeTagName(tagName)}-${objectToSortedKVArray(attributes).join('-')}` @@ -152,6 +153,18 @@ export default class PostNodeBuilder { return atom } + /** + * @param {String} name + * @param {String} [value=''] + * @param {Markup[]} [markups=[]] + * @return {Atom} + */ + createElementAtom(tagName: string, markups: Markup[] = []): ElementAtom { + const atom = new ElementAtom(tagName, markups) + atom.builder = this + return atom + } + /** * @param {String} tagName * @param {Object} attributes Key-value pairs of attributes for the markup diff --git a/src/js/models/render-node.ts b/src/js/models/render-node.ts index 69295c7ff..0695c2e6f 100644 --- a/src/js/models/render-node.ts +++ b/src/js/models/render-node.ts @@ -5,7 +5,7 @@ import assert, { unwrap } from '../utils/assert' import RenderTree from './render-tree' import { Option } from '../utils/types' import CardNode from './card-node' -import AtomNode from './atom-node' +import AtomNode from './atoms/atom-node' import Section from './_section' import Markuperable from '../utils/markuperable' import { PostNode } from './post-node-builder' diff --git a/src/js/parsers/dom.ts b/src/js/parsers/dom.ts index f98e664e5..2212c0018 100644 --- a/src/js/parsers/dom.ts +++ b/src/js/parsers/dom.ts @@ -15,7 +15,7 @@ import { Cloneable } from '../models/_cloneable' import MarkupSection, { hasInferredTagName } from '../models/markup-section' import RenderTree from '../models/render-tree' import { isMarker } from '../models/marker' -import { isAtom } from '../models/atom' +import { isAtom } from '../models/atoms/atom' import RenderNode from '../models/render-node' import Markuperable from '../utils/markuperable' import ListItem from '../models/list-item' diff --git a/src/js/parsers/mobiledoc/index.ts b/src/js/parsers/mobiledoc/index.ts index f81d302b2..1605dc4ef 100644 --- a/src/js/parsers/mobiledoc/index.ts +++ b/src/js/parsers/mobiledoc/index.ts @@ -7,11 +7,13 @@ import { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_2, MobiledocV0_2 } from '../.. import { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3, MobiledocV0_3 } from '../../renderers/mobiledoc/0-3' import { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3_1, MobiledocV0_3_1 } from '../../renderers/mobiledoc/0-3-1' import { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3_2, MobiledocV0_3_2 } from '../../renderers/mobiledoc/0-3-2' +import { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3_3, MobiledocV0_3_3 } from '../../renderers/mobiledoc/0-3-3' + import assert from '../../utils/assert' import PostNodeBuilder from '../../models/post-node-builder' import Post from '../../models/post' -type Mobiledoc = MobiledocV0_2 | MobiledocV0_3 | MobiledocV0_3_1 | MobiledocV0_3_2 +type Mobiledoc = MobiledocV0_2 | MobiledocV0_3 | MobiledocV0_3_1 | MobiledocV0_3_2 | MobiledocV0_3_3 export default { parse(builder: PostNodeBuilder, mobiledoc: Mobiledoc): Post { @@ -23,7 +25,8 @@ export default { case MOBILEDOC_VERSION_0_3_1: return new MobiledocParser_0_3_1(builder).parse(mobiledoc) case MOBILEDOC_VERSION_0_3_2: - return new MobiledocParser_0_3_2(builder).parse(mobiledoc) + case MOBILEDOC_VERSION_0_3_3: + return new MobiledocParser_0_3_2(builder).parse(mobiledoc as any) default: assert(`Unknown version of mobiledoc parser requested: ${(mobiledoc as any).version}`, false) } diff --git a/src/js/renderers/editor-dom.ts b/src/js/renderers/editor-dom.ts index 017dd6334..71f090bb4 100644 --- a/src/js/renderers/editor-dom.ts +++ b/src/js/renderers/editor-dom.ts @@ -1,6 +1,6 @@ import CardNode, { CardData, CardRenderHook } from '../models/card-node' import { detect, forEach, ForEachable } from '../utils/array-utils' -import AtomNode, { AtomData, AtomRenderHook } from '../models/atom-node' +import AtomNode, { AtomData, AtomRenderHook } from '../models/atoms/atom-node' import { Type } from '../models/types' import { startsWith, endsWith } from '../utils/string-utils' import { addClassName, removeClassName } from '../utils/dom-utils' @@ -15,7 +15,7 @@ import { TagNameable } from '../models/_tag-nameable' import ListSection from '../models/list-section' import RenderNode from '../models/render-node' import { Option, Maybe, Dict } from '../utils/types' -import Atom from '../models/atom' +import Atom, { AtomType } from '../models/atoms/atom' import Editor from '../editor/editor' import { hasChildSections } from '../models/_has-child-sections' import Post from '../models/post' @@ -24,6 +24,7 @@ import Image from '../models/image' import Card from '../models/card' import RenderTree from '../models/render-tree' import { PostNode } from '../models/post-node-builder' +import CustomAtom from '../models/atoms/atom' export const CARD_ELEMENT_CLASS_NAME = '__mobiledoc-card' export const NO_BREAK_SPACE = '\u00A0' @@ -172,12 +173,10 @@ function attachElementToParent(element: Node, parentElement: Node, previousRende } } -function renderAtom(atom: Atom, element: HTMLElement, previousRenderNode: Option) { - let atomElement = document.createElement('span') - atomElement.contentEditable = 'false' +function renderElementAtom(atomModel: Atom, renderNode: RenderNode) { + let atomElement = document.createElement(atomModel.name) let wrapper = document.createElement('span') - addClassName(wrapper, ATOM_CLASS_NAME) let headTextNode = renderInlineCursorPlaceholder() let tailTextNode = renderInlineCursorPlaceholder() @@ -185,6 +184,30 @@ function renderAtom(atom: Atom, element: HTMLElement, previousRenderNode: Option wrapper.appendChild(atomElement) wrapper.appendChild(tailTextNode) + renderNode.headTextNode = headTextNode + renderNode.tailTextNode = tailTextNode + renderNode.element = wrapper + renderNode.markupElement = atomElement +} + +function renderCustomAtom(atom: CustomAtom, element: HTMLElement, previousRenderNode: Option) { + let atomElement: HTMLElement + let wrapper: HTMLElement + let headTextNode: Text + let tailTextNode: Text + + atomElement = document.createElement('span') + atomElement.contentEditable = 'false' + + wrapper = document.createElement('span') + addClassName(wrapper, ATOM_CLASS_NAME) + headTextNode = renderInlineCursorPlaceholder() + tailTextNode = renderInlineCursorPlaceholder() + + wrapper.appendChild(headTextNode) + wrapper.appendChild(atomElement) + wrapper.appendChild(tailTextNode) + let wrappedElement = wrapElement(wrapper, atom.openedMarkups) attachElementToParent(wrappedElement, element, previousRenderNode) @@ -481,29 +504,42 @@ class Visitor { } const { editor, options } = this - const { wrapper, markupElement, atomElement, headTextNode, tailTextNode } = renderAtom( - atomModel, - parentElement as HTMLElement, - renderNode.prev - ) - const atom = this._findAtom(atomModel.name) - - let atomNode = renderNode.atomNode - if (!atomNode) { - // create new AtomNode - atomNode = new AtomNode(editor, atom, atomModel, atomElement, options) - } else { - // retarget atomNode to new atom element - atomNode.element = atomElement - } - atomNode.render() + switch (atomModel.atomType) { + case AtomType.ELEMENT: { + if (!renderNode.element) { + renderElementAtom(atomModel, renderNode) + } - renderNode.atomNode = atomNode - renderNode.element = wrapper - renderNode.headTextNode = headTextNode - renderNode.tailTextNode = tailTextNode - renderNode.markupElement = markupElement + attachElementToParent(renderNode.element!, parentElement, renderNode.prev) + break + } + case AtomType.CUSTOM: { + const { wrapper, markupElement, atomElement, headTextNode, tailTextNode } = renderCustomAtom( + atomModel, + parentElement as HTMLElement, + renderNode.prev + ) + const atom = this._findAtom(atomModel.name) + + let atomNode = renderNode.atomNode + if (!atomNode) { + // create new AtomNode + atomNode = new AtomNode(editor, atom, atomModel, atomElement, options) + } else { + // retarget atomNode to new atom element + atomNode.element = atomElement + } + + atomNode.render() + + renderNode.atomNode = atomNode + renderNode.element = wrapper + renderNode.headTextNode = headTextNode + renderNode.tailTextNode = tailTextNode + renderNode.markupElement = markupElement + } + } } } diff --git a/src/js/renderers/mobiledoc/0-3-1.ts b/src/js/renderers/mobiledoc/0-3-1.ts index 43bf61a66..99ea20e9b 100644 --- a/src/js/renderers/mobiledoc/0-3-1.ts +++ b/src/js/renderers/mobiledoc/0-3-1.ts @@ -9,7 +9,7 @@ import Image from '../../models/image' import Card from '../../models/card' import Marker from '../../models/marker' import Markup from '../../models/markup' -import Atom from '../../models/atom' +import Atom from '../../models/atoms/atom' import { Dict } from '../../utils/types' import { MobiledocSectionKind, MobiledocMarkerKind } from './constants' import { MobiledocMarker, MobiledocSection, MobiledocMarkerType, MobiledocAtom, MobiledocCard } from './0-3' diff --git a/src/js/renderers/mobiledoc/0-3-2.ts b/src/js/renderers/mobiledoc/0-3-2.ts index 4777d2d65..455a81c12 100644 --- a/src/js/renderers/mobiledoc/0-3-2.ts +++ b/src/js/renderers/mobiledoc/0-3-2.ts @@ -9,7 +9,7 @@ import Image from '../../models/image' import Card from '../../models/card' import Marker from '../../models/marker' import Markup from '../../models/markup' -import Atom from '../../models/atom' +import Atom from '../../models/atoms/atom' import { Dict } from '../../utils/types' import { MobiledocSectionKind, MobiledocMarkerKind } from './constants' import { MobiledocCard, MobiledocAtom, MobiledocMarker, MobiledocSection, MobiledocMarkerType } from './0-3' diff --git a/src/js/renderers/mobiledoc/0-3-3.ts b/src/js/renderers/mobiledoc/0-3-3.ts new file mode 100644 index 000000000..84b4c0d7f --- /dev/null +++ b/src/js/renderers/mobiledoc/0-3-3.ts @@ -0,0 +1,206 @@ +import { visit, visitArray, compile, Opcodes } from '../../utils/compiler' +import { objectToSortedKVArray } from '../../utils/array-utils' +import { Type } from '../../models/types' +import Post from '../../models/post' +import MarkupSection from '../../models/markup-section' +import ListSection from '../../models/list-section' +import ListItem from '../../models/list-item' +import Image from '../../models/image' +import Card from '../../models/card' +import Marker from '../../models/marker' +import Markup from '../../models/markup' +import Atom, { AtomType } from '../../models/atoms/atom' +import { Dict } from '../../utils/types' +import { MobiledocSectionKind, MobiledocMarkerKind } from './constants' +import { MobiledocCard, MobiledocAtom, MobiledocMarker, MobiledocSection, MobiledocMarkerType } from './0-3' + +export const MOBILEDOC_VERSION = '0.3.3' + +export type MobiledocAttributedMarkupSection = [MobiledocSectionKind.MARKUP, string, MobiledocMarker[], string[]] +export type MobiledocAttributedListSection = [MobiledocSectionKind.LIST, string, MobiledocMarker[][], string[]] + +export type MobiledocAttributedSection = + | MobiledocSection + | MobiledocAttributedMarkupSection + | MobiledocAttributedListSection + +const visitor = { + [Type.POST](node: Post, opcodes: Opcodes) { + opcodes.push(['openPost']) + visitArray(visitor, node.sections, opcodes) + }, + [Type.MARKUP_SECTION](node: MarkupSection, opcodes: Opcodes) { + opcodes.push(['openMarkupSection', node.tagName, objectToSortedKVArray(node.attributes)]) + visitArray(visitor, node.markers, opcodes) + }, + [Type.LIST_SECTION](node: ListSection, opcodes: Opcodes) { + opcodes.push(['openListSection', node.tagName, objectToSortedKVArray(node.attributes)]) + visitArray(visitor, node.items, opcodes) + }, + [Type.LIST_ITEM](node: ListItem, opcodes: Opcodes) { + opcodes.push(['openListItem']) + visitArray(visitor, node.markers, opcodes) + }, + [Type.IMAGE_SECTION](node: Image, opcodes: Opcodes) { + opcodes.push(['openImageSection', node.src]) + }, + [Type.CARD](node: Card, opcodes: Opcodes) { + opcodes.push(['openCardSection', node.name, node.payload]) + }, + [Type.MARKER](node: Marker, opcodes: Opcodes) { + opcodes.push(['openMarker', node.closedMarkups.length, node.value]) + visitArray(visitor, node.openedMarkups, opcodes) + }, + [Type.MARKUP](node: Markup, opcodes: Opcodes) { + opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]) + }, + [Type.ATOM](node: Atom, opcodes: Opcodes) { + switch (node.atomType) { + case AtomType.CUSTOM: + opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]) + break + case AtomType.ELEMENT: + opcodes.push(['openElementAtom', node.closedMarkups.length, node.name, node.value, node.payload]) + break + } + + visitArray(visitor, node.openedMarkups, opcodes) + }, +} +class PostOpcodeCompiler { + markupMarkerIds!: number[] + markers!: MobiledocMarker[] + sections!: MobiledocAttributedSection[] + items!: MobiledocMarker[][] + markerTypes!: MobiledocMarkerType[] + atomTypes!: MobiledocAtom[] + cardTypes!: MobiledocCard[] + result!: MobiledocV0_3_3 + + _markerTypeCache!: Dict + + openMarker(closeCount: number, value: string) { + this.markupMarkerIds = [] + this.markers.push([MobiledocMarkerKind.MARKUP, this.markupMarkerIds, closeCount, value || '']) + } + + openAtom(closeCount: number, name: string, value: string, payload: {}) { + const index = this._addAtomTypeIndex(name, value, payload) + this.markupMarkerIds = [] + this.markers.push([MobiledocMarkerKind.ATOM, this.markupMarkerIds, closeCount, index]) + } + + openElementAtom(closeCount: number, name: string) { + this.markupMarkerIds = [] + this.markers.push([MobiledocMarkerKind.ATOM, this.markupMarkerIds, closeCount, name]) + } + + openMarkupSection(tagName: string, attributes: string[]) { + this.markers = [] + if (attributes && attributes.length !== 0) { + this.sections.push([MobiledocSectionKind.MARKUP, tagName, this.markers, attributes]) + } else { + this.sections.push([MobiledocSectionKind.MARKUP, tagName, this.markers]) + } + } + + openListSection(tagName: string, attributes: string[]) { + this.items = [] + if (attributes && attributes.length !== 0) { + this.sections.push([MobiledocSectionKind.LIST, tagName, this.items, attributes]) + } else { + this.sections.push([MobiledocSectionKind.LIST, tagName, this.items]) + } + } + + openListItem() { + this.markers = [] + this.items.push(this.markers) + } + + openImageSection(url: string) { + this.sections.push([MobiledocSectionKind.IMAGE, url]) + } + + openCardSection(name: string, payload: {}) { + const index = this._addCardTypeIndex(name, payload) + this.sections.push([MobiledocSectionKind.CARD, index]) + } + + openPost() { + this.atomTypes = [] + this.cardTypes = [] + this.markerTypes = [] + this.sections = [] + this.result = { + version: MOBILEDOC_VERSION, + atoms: this.atomTypes, + cards: this.cardTypes, + markups: this.markerTypes, + sections: this.sections, + } + } + + openMarkup(tagName: string, attributes: string[]) { + const index = this._findOrAddMarkerTypeIndex(tagName, attributes) + this.markupMarkerIds.push(index) + } + + _addCardTypeIndex(cardName: string, payload: {}) { + let cardType: MobiledocCard = [cardName, payload] + this.cardTypes.push(cardType) + return this.cardTypes.length - 1 + } + + _addAtomTypeIndex(atomName: string, atomValue: string, payload: {}) { + let atomType: MobiledocAtom = [atomName, atomValue, payload] + this.atomTypes.push(atomType) + return this.atomTypes.length - 1 + } + + _findOrAddMarkerTypeIndex(tagName: string, attributesArray: string[]) { + if (!this._markerTypeCache) { + this._markerTypeCache = {} + } + const key = `${tagName}-${attributesArray.join('-')}` + + let index = this._markerTypeCache[key] + if (index === undefined) { + let markerType: MobiledocMarkerType = [tagName] + if (attributesArray.length) { + markerType.push(attributesArray) + } + this.markerTypes.push(markerType) + + index = this.markerTypes.length - 1 + this._markerTypeCache[key] = index + } + + return index + } +} + +export interface MobiledocV0_3_3 { + version: typeof MOBILEDOC_VERSION + atoms: MobiledocAtom[] + cards: MobiledocCard[] + markups: MobiledocMarkerType[] + sections: MobiledocAttributedSection[] +} + +/** + * Render from post -> mobiledoc + */ +export default { + /** + * @param {Post} + * @return {Mobiledoc} + */ + render(post: Post): MobiledocV0_3_3 { + let opcodes: Opcodes = [] + visit(visitor, post, opcodes) + let compiler = new PostOpcodeCompiler() + compile(compiler, opcodes) + return compiler.result + }, +} diff --git a/src/js/renderers/mobiledoc/0-3.ts b/src/js/renderers/mobiledoc/0-3.ts index 4427f8c3e..d2158236d 100644 --- a/src/js/renderers/mobiledoc/0-3.ts +++ b/src/js/renderers/mobiledoc/0-3.ts @@ -9,7 +9,7 @@ import Image from '../../models/image' import Card from '../../models/card' import Marker from '../../models/marker' import Markup from '../../models/markup' -import Atom from '../../models/atom' +import Atom from '../../models/atoms/atom' import { Dict } from '../../utils/types' import { MobiledocSectionKind, MobiledocMarkerKind } from './constants' @@ -53,8 +53,9 @@ const visitor = { export type MobiledocMarkupMarker = [MobiledocMarkerKind.MARKUP, number[], number, string] export type MobiledocAtomMarker = [MobiledocMarkerKind.ATOM, number[], number, number] +export type MobiledocElementAtomMarker = [MobiledocMarkerKind.ATOM, number[], number, string] -export type MobiledocMarker = MobiledocMarkupMarker | MobiledocAtomMarker +export type MobiledocMarker = MobiledocMarkupMarker | MobiledocAtomMarker | MobiledocElementAtomMarker export type MobiledocMarkupSection = [MobiledocSectionKind.MARKUP, string, MobiledocMarker[]] export type MobiledocListSection = [MobiledocSectionKind.LIST, string, MobiledocMarker[][]] diff --git a/src/js/renderers/mobiledoc/index.ts b/src/js/renderers/mobiledoc/index.ts index 7fc183f6c..521ad4a76 100644 --- a/src/js/renderers/mobiledoc/index.ts +++ b/src/js/renderers/mobiledoc/index.ts @@ -2,17 +2,19 @@ import MobiledocRenderer_0_2, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_2, Mobi import MobiledocRenderer_0_3, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3, MobiledocV0_3 } from './0-3' import MobiledocRenderer_0_3_1, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3_1, MobiledocV0_3_1 } from './0-3-1' import MobiledocRenderer_0_3_2, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3_2, MobiledocV0_3_2 } from './0-3-2' +import MobiledocRenderer_0_3_3, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3_3, MobiledocV0_3_3 } from './0-3-3' import assert from '../../utils/assert' import Post from '../../models/post' -export type Mobiledoc = MobiledocV0_2 | MobiledocV0_3 | MobiledocV0_3_1 | MobiledocV0_3_2 -export const MOBILEDOC_VERSION = MOBILEDOC_VERSION_0_3_2 +export type Mobiledoc = MobiledocV0_2 | MobiledocV0_3 | MobiledocV0_3_1 | MobiledocV0_3_2 | MobiledocV0_3_3 +export const MOBILEDOC_VERSION = MOBILEDOC_VERSION_0_3_3 interface VersionTypes { [MOBILEDOC_VERSION_0_2]: MobiledocV0_2 [MOBILEDOC_VERSION_0_3]: MobiledocV0_3 [MOBILEDOC_VERSION_0_3_1]: MobiledocV0_3_1 [MOBILEDOC_VERSION_0_3_2]: MobiledocV0_3_2 + [MOBILEDOC_VERSION_0_3_3]: MobiledocV0_3_3 } export type MobiledocVersion = keyof VersionTypes @@ -26,10 +28,12 @@ export default { return MobiledocRenderer_0_3.render(post) case MOBILEDOC_VERSION_0_3_1: return MobiledocRenderer_0_3_1.render(post) - case undefined: - case null: case MOBILEDOC_VERSION_0_3_2: return MobiledocRenderer_0_3_2.render(post) + case undefined: + case null: + case MOBILEDOC_VERSION_0_3_3: + return MobiledocRenderer_0_3_3.render(post) default: assert(`Unknown version of mobiledoc renderer requested: ${version}`, false) } diff --git a/src/js/utils/compiler.ts b/src/js/utils/compiler.ts index 778249f29..456520c43 100644 --- a/src/js/utils/compiler.ts +++ b/src/js/utils/compiler.ts @@ -23,6 +23,7 @@ export type OpcodeName = | 'openAtom' | 'openMarkup' | 'openAtom' + | 'openElementAtom' export type Opcode = [OpcodeName, ...unknown[]] export type Opcodes = Opcode[] export type Compiler = { [key in OpcodeName]?: (...params: any[]) => void } diff --git a/src/js/utils/cursor.ts b/src/js/utils/cursor.ts index dd8f5def1..6302d92b9 100644 --- a/src/js/utils/cursor.ts +++ b/src/js/utils/cursor.ts @@ -108,10 +108,10 @@ class Cursor { if (offsetInMarker > 0) { // FIXME -- if there is a next marker, focus on it? offset = 0 - node = marker.renderNode.tailTextNode + node = marker.renderNode.tailTextNode || marker.renderNode.element } else { offset = 0 - node = marker.renderNode.headTextNode + node = marker.renderNode.headTextNode || marker.renderNode.element } } else { node = marker.renderNode.element @@ -162,6 +162,7 @@ class Cursor { } const range = document.createRange() + assertExistsInDocument(node, document) range.setStart(node, offset) if (direction === Direction.BACKWARD && isFullSelection(this.selection)) { this.selection.addRange(range) @@ -204,4 +205,16 @@ class Cursor { } } +function assertExistsInDocument(node: Node, doc: Document) { + let parent = node.parentNode + while (parent) { + if (parent === doc) { + return + } + parent = parent.parentNode + } + + throw new Error('node did not exist in document') +} + export default Cursor diff --git a/src/js/utils/cursor/position.ts b/src/js/utils/cursor/position.ts index eb86a68d6..d68c61947 100644 --- a/src/js/utils/cursor/position.ts +++ b/src/js/utils/cursor/position.ts @@ -11,7 +11,7 @@ import Section from '../../models/_section' import RenderNode from '../../models/render-node' import Card, { isCardSection } from '../../models/card' import Markuperable from '../markuperable' -import { isAtom } from '../../models/atom' +import { isAtom } from '../../models/atoms/atom' const { FORWARD, BACKWARD } = Direction diff --git a/src/js/utils/key.ts b/src/js/utils/key.ts index 1432649fd..ec0f78a1a 100644 --- a/src/js/utils/key.ts +++ b/src/js/utils/key.ts @@ -190,6 +190,10 @@ export default class Key { return this.isKey('ENTER') } + isShiftEnter() { + return this.isKey('ENTER') && !!this.isShift() + } + /* * If the key is the actual shift key. This is false when the shift key * is held down and the source `event` is not the shift key. diff --git a/tests/acceptance/editor-input-handlers-test.js b/tests/acceptance/editor-input-handlers-test.js deleted file mode 100644 index f5b8e301d..000000000 --- a/tests/acceptance/editor-input-handlers-test.js +++ /dev/null @@ -1,371 +0,0 @@ -import Helpers from '../test-helpers'; -import Range from 'mobiledoc-kit/utils/cursor/range'; -import { NO_BREAK_SPACE } from 'mobiledoc-kit/renderers/editor-dom'; -import { TAB, ENTER } from 'mobiledoc-kit/utils/characters'; -import { MODIFIERS } from 'mobiledoc-kit/utils/key'; - -const { module, test } = Helpers; -const { editor: { buildFromText } } = Helpers; -const { postAbstract: { DEFAULT_ATOM_NAME } } = Helpers; - -let editor, editorElement; - -function renderEditor(...args) { - editor = Helpers.mobiledoc.renderInto(editorElement, ...args); - editor.selectRange(editor.post.tailPosition()); - return editor; -} - -let atom = { - name: DEFAULT_ATOM_NAME, - type: 'dom', - render() {} -}; - -module('Acceptance: Editor: Text Input Handlers', { - beforeEach() { - editorElement = $('#editor')[0]; - }, - afterEach() { - if (editor) { editor.destroy(); } - } -}); - -const headerTests = [{ - text: '#', - toInsert: ' ', - headerTagName: 'h1' -}, { - text: '##', - toInsert: ' ', - headerTagName: 'h2' -}, { - text: '###', - toInsert: ' ', - headerTagName: 'h3' -}, { - text: '####', - toInsert: ' ', - headerTagName: 'h4' -}, { - text: '#####', - toInsert: ' ', - headerTagName: 'h5' -}, { - text: '######', - toInsert: ' ', - headerTagName: 'h6' -}]; - -headerTests.forEach(({text, toInsert, headerTagName}) => { - test(`typing "${text}${toInsert}" converts to ${headerTagName}`, (assert) => { - renderEditor(({post, markupSection, marker}) => { - return post([markupSection('p',[marker(text)])]); - }); - assert.hasElement('#editor p', 'precond - has p'); - Helpers.dom.insertText(editor, toInsert); - assert.hasNoElement('#editor p', 'p is gone'); - assert.hasElement(`#editor ${headerTagName}`, `p -> ${headerTagName}`); - - // Different browsers report different selections, so we grab the selection - // here and then set it to what we expect it to be, and compare what - // window.getSelection() reports. - // E.g., in Firefox getSelection() reports that the anchorNode is the "br", - // but Safari and Chrome report that the anchorNode is the header element - let selection = window.getSelection(); - - let cursorElement = $(`#editor ${headerTagName} br`)[0]; - assert.ok(cursorElement, 'has cursorElement'); - Helpers.dom.selectRange(cursorElement, 0, cursorElement, 0); - - let newSelection = window.getSelection(); - assert.equal(selection.anchorNode, newSelection.anchorNode, 'correct anchorNode'); - assert.equal(selection.focusNode, newSelection.focusNode, 'correct focusNode'); - assert.equal(selection.anchorOffset, newSelection.anchorOffset, 'correct anchorOffset'); - assert.equal(selection.focusOffset, newSelection.focusOffset, 'correct focusOffset'); - - Helpers.dom.insertText(editor, 'X'); - assert.hasElement(`#editor ${headerTagName}:contains(X)`, 'text is inserted correctly'); - }); - - test(`typing "${text}" but not "${toInsert}" does not convert to ${headerTagName}`, (assert) => { - editor = buildFromText(text, {element: editorElement}); - assert.hasElement('#editor p', 'precond - has p'); - Helpers.dom.insertText(editor, 'X'); - - assert.hasElement('#editor p', 'still has p'); - assert.hasNoElement(`#editor ${headerTagName}`, `does not change to ${headerTagName}`); - }); -}); - -test('typing "* " converts to ul > li', (assert) => { - renderEditor(({post, markupSection, marker}) => { - return post([markupSection('p',[marker('*')])]); - }); - - Helpers.dom.insertText(editor, ' '); - assert.hasNoElement('#editor p', 'p is gone'); - assert.hasElement('#editor ul > li', 'p -> "ul > li"'); - - // Store the selection so we can compare later - let selection = window.getSelection(); - let cursorElement = $('#editor ul > li > br')[0]; - assert.ok(cursorElement, 'has cursorElement for cursor position'); - Helpers.dom.selectRange(cursorElement, 0, cursorElement, 0); - - let newSelection = window.getSelection(); - assert.equal(selection.anchorNode, newSelection.anchorNode, 'correct anchorNode'); - assert.equal(selection.focusNode, newSelection.focusNode, 'correct focusNode'); - assert.equal(selection.anchorOffset, newSelection.anchorOffset, 'correct anchorOffset'); - assert.equal(selection.focusOffset, newSelection.focusOffset, 'correct focusOffset'); - - Helpers.dom.insertText(editor, 'X'); - assert.hasElement('#editor ul > li:contains(X)', 'text is inserted correctly'); -}); - -// see https://github.com/bustle/mobiledoc-kit/issues/280 -test('typing "* " at start of markup section does not remove it', (assert) => { - renderEditor(({post, markupSection, marker}) => { - return post([markupSection('p',[marker('*abc')])]); - }); - - editor.selectRange(editor.post.sections.head.toPosition(1)); - - Helpers.dom.insertText(editor, ' '); - assert.hasElement('#editor p:contains(* abc)', 'p is still there'); -}); - -test('typing "* " inside of a list section does not create a new list section', (assert) => { - renderEditor(({post, listSection, listItem, marker}) => { - return post([listSection('ul', [listItem([marker('*')])])]); - }); - let position = editor.post.sections.head.items.head.tailPosition(); - editor.selectRange(position); - - assert.hasElement('#editor ul > li:contains(*)', 'precond - has li'); - - Helpers.dom.insertText(editor, ' '); - // note: the actual text is "* ", so only check that the "*" is there, - assert.hasElement('#editor ul > li', 'still has li'); - let el = $('#editor ul > li')[0]; - assert.equal(el.textContent, `*${NO_BREAK_SPACE}`); -}); - -test('typing "1 " converts to ol > li', (assert) => { - editor = buildFromText(['1|'], {element: editorElement}); - Helpers.dom.insertText(editor, ' '); - assert.hasNoElement('#editor p', 'p is gone'); - assert.hasElement('#editor ol > li', 'p -> "ol > li"'); - - // Store the selection so we can compare later - let selection = window.getSelection(); - let cursorElement = $('#editor ol > li > br')[0]; - assert.ok(cursorElement, 'has cursorElement for cursor position'); - Helpers.dom.selectRange(cursorElement, 0, cursorElement, 0); - - let newSelection = window.getSelection(); - assert.equal(selection.anchorNode, newSelection.anchorNode, 'correct anchorNode'); - assert.equal(selection.focusNode, newSelection.focusNode, 'correct focusNode'); - assert.equal(selection.anchorOffset, newSelection.anchorOffset, 'correct anchorOffset'); - assert.equal(selection.focusOffset, newSelection.focusOffset, 'correct focusOffset'); - - Helpers.dom.insertText(editor, 'X'); - - assert.hasElement('#editor li:contains(X)', 'text is inserted correctly'); -}); - -test('typing "1. " converts to ol > li', (assert) => { - editor = buildFromText('1.|', {element: editorElement}); - Helpers.dom.insertText(editor, ' '); - assert.hasNoElement('#editor p', 'p is gone'); - assert.hasElement('#editor ol > li', 'p -> "ol > li"'); - Helpers.dom.insertText(editor, 'X'); - - assert.hasElement('#editor li:contains(X)', 'text is inserted correctly'); -}); - -test('an input handler will trigger anywhere in the text', (assert) => { - editor = buildFromText('@abc@', {element: editorElement, atoms: [atom]}); - - let expandCount = 0; - let lastMatches; - editor.onTextInput({ - name: 'at', - text: '@', - run: (editor, matches) => { - expandCount++; - lastMatches = matches; - } - }); - - // at start - editor.selectRange(editor.post.headPosition()); - Helpers.dom.insertText(editor, '@'); - assert.equal(expandCount, 1, 'expansion was run at start'); - assert.deepEqual(lastMatches, ['@'], 'correct match at start'); - - // middle - editor.selectRange(editor.post.sections.head.toPosition('@'.length + 1 + 'ab'.length)); - Helpers.dom.insertText(editor, '@'); - assert.equal(expandCount, 2, 'expansion was run at middle'); - assert.deepEqual(lastMatches, ['@'], 'correct match at middle'); - - // end - editor.selectRange(editor.post.tailPosition()); - Helpers.dom.insertText(editor, '@'); - assert.equal(expandCount, 3, 'expansion was run at end'); - assert.deepEqual(lastMatches, ['@'], 'correct match at end'); -}); - -test('an input handler can provide a `match` instead of `text`', (assert) => { - editor = buildFromText('@abc@', {element: editorElement, atoms: [atom]}); - - let expandCount = 0; - let lastMatches; - let regex = /.(.)X$/; - editor.onTextInput({ - name: 'test', - match: regex, - run: (editor, matches) => { - expandCount++; - lastMatches = matches; - } - }); - - // at start - editor.selectRange(new Range(editor.post.headPosition())); - Helpers.dom.insertText(editor, 'abX'); - assert.equal(expandCount, 1, 'expansion was run at start'); - assert.deepEqual(lastMatches, regex.exec('abX'), 'correct match at start'); - - // middle - editor.selectRange(editor.post.sections.head.toPosition('abX'.length + 1 + 'ab'.length)); - Helpers.dom.insertText(editor, '..X'); - assert.equal(expandCount, 2, 'expansion was run at middle'); - assert.deepEqual(lastMatches, regex.exec('..X'), 'correct match at middle'); - - // end - editor.selectRange(new Range(editor.post.tailPosition())); - Helpers.dom.insertText(editor, '**X'); - assert.equal(expandCount, 3, 'expansion was run at end'); - assert.deepEqual(lastMatches, regex.exec('**X'), 'correct match at end'); -}); - -test('an input handler can provide a `match` that matches at start and end', (assert) => { - editor = Helpers.editor.buildFromText(['@abc@'], {element: editorElement, atoms: [atom]}); - - let expandCount = 0; - let lastMatches; - let regex = /^\d\d\d$/; - editor.onTextInput({ - name: 'test', - match: regex, - run: (editor, matches) => { - expandCount++; - lastMatches = matches; - } - }); - - // at start - editor.selectRange(editor.post.headPosition()); - Helpers.dom.insertText(editor, '123'); - assert.equal(expandCount, 1, 'expansion was run at start'); - assert.deepEqual(lastMatches, regex.exec('123'), 'correct match at start'); - - // middle - editor.selectRange(editor.post.sections.head.toPosition('123'.length+2)); - Helpers.dom.insertText(editor, '123'); - assert.equal(expandCount, 1, 'expansion was not run at middle'); - - // end - editor.selectRange(editor.post.tailPosition()); - Helpers.dom.insertText(editor, '123'); - assert.equal(expandCount, 1, 'expansion was not run at end'); -}); - -// See https://github.com/bustle/mobiledoc-kit/issues/400 -test('input handler can be triggered by TAB', (assert) => { - editor = Helpers.editor.buildFromText('abc|', {element: editorElement}); - - let didMatch; - editor.onTextInput({ - name: 'test', - match: /abc\t/, - run() { - didMatch = true; - } - }); - - Helpers.dom.insertText(editor, TAB); - - assert.ok(didMatch); -}); - -test('input handler can be triggered by ENTER', (assert) => { - editor = Helpers.editor.buildFromText('abc|', {element: editorElement}); - - let didMatch; - editor.onTextInput({ - name: 'test', - match: /abc\n/, - run() { - didMatch = true; - } - }); - - Helpers.dom.insertText(editor, ENTER); - - assert.ok(didMatch); -}); - -// See https://github.com/bustle/mobiledoc-kit/issues/565 -test('typing ctrl-TAB does not insert TAB text', (assert) => { - editor = Helpers.editor.buildFromText('abc|', {element: editorElement}); - - Helpers.dom.triggerKeyCommand(editor, TAB, [MODIFIERS.CTRL]); - - assert.equal(editorElement.textContent, 'abc', 'no TAB is inserted'); -}); - -test('can unregister all handlers', (assert) => { - editor = Helpers.editor.buildFromText(''); - // there are 3 default helpers - assert.equal(editor._eventManager._textInputHandler._handlers.length, 3); - editor.onTextInput({ - name: 'first', - match: /abc\t/, - run() {} - }); - editor.onTextInput({ - name: 'second', - match: /abc\t/, - run() {} - }); - assert.equal(editor._eventManager._textInputHandler._handlers.length, 5); - editor.unregisterAllTextInputHandlers(); - assert.equal(editor._eventManager._textInputHandler._handlers.length, 0); -}); - -test('can unregister handler by name', (assert) => { - editor = Helpers.editor.buildFromText(''); - const handlerName = 'ul'; - let handlers = editor._eventManager._textInputHandler._handlers; - assert.ok(handlers.filter(handler => handler.name === handlerName).length); - editor.unregisterTextInputHandler(handlerName); - assert.notOk(handlers.filter(handler => handler.name === handlerName).length); -}); - -test('can unregister handlers by duplicate name', (assert) => { - editor = Helpers.editor.buildFromText(''); - const handlerName = 'ul'; - editor.onTextInput({ - name: handlerName, - match: /abc/, - run() {} - }); - let handlers = editor._eventManager._textInputHandler._handlers; - assert.equal(handlers.length, 4); // 3 default + 1 custom handlers - editor.unregisterTextInputHandler(handlerName); - assert.equal(handlers.length, 2); - assert.notOk(handlers.filter(handler => handler.name === handlerName).length); -}); diff --git a/tests/acceptance/editor-input-handlers-test.ts b/tests/acceptance/editor-input-handlers-test.ts new file mode 100644 index 000000000..f5989b709 --- /dev/null +++ b/tests/acceptance/editor-input-handlers-test.ts @@ -0,0 +1,398 @@ +import Helpers from '../test-helpers' +import Range from 'mobiledoc-kit/utils/cursor/range' +import { NO_BREAK_SPACE } from 'mobiledoc-kit/renderers/editor-dom' +import { TAB, ENTER } from 'mobiledoc-kit/utils/characters' +import { MODIFIERS } from 'mobiledoc-kit/utils/key' +import { Editor } from 'mobiledoc-kit' +import { BuildCallback } from 'tests/helpers/post-abstract' +import { EditorOptions } from 'mobiledoc-kit/editor/editor' +import ListSection from 'mobiledoc-kit/models/list-section' +import { AtomData } from 'mobiledoc-kit/models/atoms/atom-node' + +const { module, test } = Helpers +const { + editor: { buildFromText }, +} = Helpers +const { + postAbstract: { DEFAULT_ATOM_NAME }, +} = Helpers + +let editor: Editor +let editorElement: HTMLElement + +function renderEditor(treeFn: BuildCallback, editorOptions: EditorOptions = {}) { + editor = Helpers.mobiledoc.renderInto(editorElement, treeFn, editorOptions) + editor.selectRange(editor.post.tailPosition()) + return editor +} + +let atom: AtomData = { + name: DEFAULT_ATOM_NAME, + type: 'dom', + render() {}, +} + +module('Acceptance: Editor: Text Input Handlers', { + beforeEach() { + editorElement = $('#editor')[0] + }, + afterEach() { + if (editor) { + editor.destroy() + } + }, +}) + +const headerTests = [ + { + text: '#', + toInsert: ' ', + headerTagName: 'h1', + }, + { + text: '##', + toInsert: ' ', + headerTagName: 'h2', + }, + { + text: '###', + toInsert: ' ', + headerTagName: 'h3', + }, + { + text: '####', + toInsert: ' ', + headerTagName: 'h4', + }, + { + text: '#####', + toInsert: ' ', + headerTagName: 'h5', + }, + { + text: '######', + toInsert: ' ', + headerTagName: 'h6', + }, +] + +headerTests.forEach(({ text, toInsert, headerTagName }) => { + test(`typing "${text}${toInsert}" converts to ${headerTagName}`, assert => { + renderEditor(({ post, markupSection, marker }) => { + return post([markupSection('p', [marker(text)])]) + }) + assert.hasElement('#editor p', 'precond - has p') + Helpers.dom.insertText(editor, toInsert) + assert.hasNoElement('#editor p', 'p is gone') + assert.hasElement(`#editor ${headerTagName}`, `p -> ${headerTagName}`) + + // Different browsers report different selections, so we grab the selection + // here and then set it to what we expect it to be, and compare what + // window.getSelection() reports. + // E.g., in Firefox getSelection() reports that the anchorNode is the "br", + // but Safari and Chrome report that the anchorNode is the header element + let selection = window.getSelection() + + let cursorElement = $(`#editor ${headerTagName} br`)[0] + assert.ok(cursorElement, 'has cursorElement') + Helpers.dom.selectRange(cursorElement, 0, cursorElement, 0) + + let newSelection = window.getSelection() + assert.equal(selection!.anchorNode, newSelection!.anchorNode, 'correct anchorNode') + assert.equal(selection!.focusNode, newSelection!.focusNode, 'correct focusNode') + assert.equal(selection!.anchorOffset, newSelection!.anchorOffset, 'correct anchorOffset') + assert.equal(selection!.focusOffset, newSelection!.focusOffset, 'correct focusOffset') + + Helpers.dom.insertText(editor, 'X') + assert.hasElement(`#editor ${headerTagName}:contains(X)`, 'text is inserted correctly') + }) + + test(`typing "${text}" but not "${toInsert}" does not convert to ${headerTagName}`, assert => { + editor = buildFromText(text, { element: editorElement }) + assert.hasElement('#editor p', 'precond - has p') + Helpers.dom.insertText(editor, 'X') + + assert.hasElement('#editor p', 'still has p') + assert.hasNoElement(`#editor ${headerTagName}`, `does not change to ${headerTagName}`) + }) +}) + +test('typing "* " converts to ul > li', assert => { + renderEditor(({ post, markupSection, marker }) => { + return post([markupSection('p', [marker('*')])]) + }) + + Helpers.dom.insertText(editor, ' ') + assert.hasNoElement('#editor p', 'p is gone') + assert.hasElement('#editor ul > li', 'p -> "ul > li"') + + // Store the selection so we can compare later + let selection = window.getSelection() + let cursorElement = $('#editor ul > li > br')[0] + assert.ok(cursorElement, 'has cursorElement for cursor position') + Helpers.dom.selectRange(cursorElement, 0, cursorElement, 0) + + let newSelection = window.getSelection() + assert.equal(selection!.anchorNode, newSelection!.anchorNode, 'correct anchorNode') + assert.equal(selection!.focusNode, newSelection!.focusNode, 'correct focusNode') + assert.equal(selection!.anchorOffset, newSelection!.anchorOffset, 'correct anchorOffset') + assert.equal(selection!.focusOffset, newSelection!.focusOffset, 'correct focusOffset') + + Helpers.dom.insertText(editor, 'X') + assert.hasElement('#editor ul > li:contains(X)', 'text is inserted correctly') +}) + +// see https://github.com/bustle/mobiledoc-kit/issues/280 +test('typing "* " at start of markup section does not remove it', assert => { + renderEditor(({ post, markupSection, marker }) => { + return post([markupSection('p', [marker('*abc')])]) + }) + + editor.selectRange(editor.post.sections.head!.toPosition(1)) + + Helpers.dom.insertText(editor, ' ') + assert.hasElement('#editor p:contains(* abc)', 'p is still there') +}) + +test('typing "* " inside of a list section does not create a new list section', assert => { + renderEditor(({ post, listSection, listItem, marker }) => { + return post([listSection('ul', [listItem([marker('*')])])]) + }) + let position = (editor.post.sections.head! as ListSection).items.head!.tailPosition() + editor.selectRange(position) + + assert.hasElement('#editor ul > li:contains(*)', 'precond - has li') + + Helpers.dom.insertText(editor, ' ') + // note: the actual text is "* ", so only check that the "*" is there, + assert.hasElement('#editor ul > li', 'still has li') + let el = $('#editor ul > li')[0] + assert.equal(el.textContent, `*${NO_BREAK_SPACE}`) +}) + +test('typing "1 " converts to ol > li', assert => { + editor = buildFromText(['1|'], { element: editorElement }) + Helpers.dom.insertText(editor, ' ') + assert.hasNoElement('#editor p', 'p is gone') + assert.hasElement('#editor ol > li', 'p -> "ol > li"') + + // Store the selection so we can compare later + let selection = window.getSelection() + let cursorElement = $('#editor ol > li > br')[0] + assert.ok(cursorElement, 'has cursorElement for cursor position') + Helpers.dom.selectRange(cursorElement, 0, cursorElement, 0) + + let newSelection = window.getSelection() + assert.equal(selection!.anchorNode, newSelection!.anchorNode, 'correct anchorNode') + assert.equal(selection!.focusNode, newSelection!.focusNode, 'correct focusNode') + assert.equal(selection!.anchorOffset, newSelection!.anchorOffset, 'correct anchorOffset') + assert.equal(selection!.focusOffset, newSelection!.focusOffset, 'correct focusOffset') + + Helpers.dom.insertText(editor, 'X') + + assert.hasElement('#editor li:contains(X)', 'text is inserted correctly') +}) + +test('typing "1. " converts to ol > li', assert => { + editor = buildFromText('1.|', { element: editorElement }) + Helpers.dom.insertText(editor, ' ') + assert.hasNoElement('#editor p', 'p is gone') + assert.hasElement('#editor ol > li', 'p -> "ol > li"') + Helpers.dom.insertText(editor, 'X') + + assert.hasElement('#editor li:contains(X)', 'text is inserted correctly') +}) + +test('an input handler will trigger anywhere in the text', assert => { + editor = buildFromText('@abc@', { element: editorElement, atoms: [atom] }) + + let expandCount = 0 + let lastMatches + editor.onTextInput({ + name: 'at', + text: '@', + run: (_, matches) => { + expandCount++ + lastMatches = matches + }, + }) + + // at start + editor.selectRange(editor.post.headPosition()) + Helpers.dom.insertText(editor, '@') + assert.equal(expandCount, 1, 'expansion was run at start') + assert.deepEqual(lastMatches, ['@'], 'correct match at start') + + // middle + editor.selectRange(editor.post.sections.head!.toPosition('@'.length + 1 + 'ab'.length)) + Helpers.dom.insertText(editor, '@') + assert.equal(expandCount, 2, 'expansion was run at middle') + assert.deepEqual(lastMatches, ['@'], 'correct match at middle') + + // end + editor.selectRange(editor.post.tailPosition()) + Helpers.dom.insertText(editor, '@') + assert.equal(expandCount, 3, 'expansion was run at end') + assert.deepEqual(lastMatches, ['@'], 'correct match at end') +}) + +test('an input handler can provide a `match` instead of `text`', assert => { + editor = buildFromText('@abc@', { element: editorElement, atoms: [atom] }) + + let expandCount = 0 + let lastMatches + let regex = /.(.)X$/ + editor.onTextInput({ + name: 'test', + match: regex, + run: (_, matches) => { + expandCount++ + lastMatches = matches + }, + }) + + // at start + editor.selectRange(new Range(editor.post.headPosition())) + Helpers.dom.insertText(editor, 'abX') + assert.equal(expandCount, 1, 'expansion was run at start') + assert.deepEqual(lastMatches, regex.exec('abX'), 'correct match at start') + + // middle + editor.selectRange(editor.post.sections.head!.toPosition('abX'.length + 1 + 'ab'.length)) + Helpers.dom.insertText(editor, '..X') + assert.equal(expandCount, 2, 'expansion was run at middle') + assert.deepEqual(lastMatches, regex.exec('..X'), 'correct match at middle') + + // end + editor.selectRange(new Range(editor.post.tailPosition())) + Helpers.dom.insertText(editor, '**X') + assert.equal(expandCount, 3, 'expansion was run at end') + assert.deepEqual(lastMatches, regex.exec('**X'), 'correct match at end') +}) + +test('an input handler can provide a `match` that matches at start and end', assert => { + editor = Helpers.editor.buildFromText(['@abc@'], { element: editorElement, atoms: [atom] }) + + let expandCount = 0 + let lastMatches + let regex = /^\d\d\d$/ + editor.onTextInput({ + name: 'test', + match: regex, + run: (_, matches) => { + expandCount++ + lastMatches = matches + }, + }) + + // at start + editor.selectRange(editor.post.headPosition()) + Helpers.dom.insertText(editor, '123') + assert.equal(expandCount, 1, 'expansion was run at start') + assert.deepEqual(lastMatches, regex.exec('123'), 'correct match at start') + + // middle + editor.selectRange(editor.post.sections.head!.toPosition('123'.length + 2)) + Helpers.dom.insertText(editor, '123') + assert.equal(expandCount, 1, 'expansion was not run at middle') + + // end + editor.selectRange(editor.post.tailPosition()) + Helpers.dom.insertText(editor, '123') + assert.equal(expandCount, 1, 'expansion was not run at end') +}) + +// See https://github.com/bustle/mobiledoc-kit/issues/400 +test('input handler can be triggered by TAB', assert => { + editor = Helpers.editor.buildFromText('abc|', { element: editorElement }) + + let didMatch + editor.onTextInput({ + name: 'test', + match: /abc\t/, + run() { + didMatch = true + }, + }) + + Helpers.dom.insertText(editor, TAB) + + assert.ok(didMatch) +}) + +test('input handler can be triggered by ENTER', assert => { + editor = Helpers.editor.buildFromText('abc|', { element: editorElement }) + + let didMatch + editor.onTextInput({ + name: 'test', + match: /abc\n/, + run() { + didMatch = true + }, + }) + + Helpers.dom.insertText(editor, ENTER) + + assert.ok(didMatch) +}) + +// See https://github.com/bustle/mobiledoc-kit/issues/565 +test('typing ctrl-TAB does not insert TAB text', assert => { + editor = Helpers.editor.buildFromText('abc|', { element: editorElement }) + + Helpers.dom.triggerKeyCommand(editor, 'TAB', [MODIFIERS.CTRL]) + + assert.equal(editorElement.textContent, 'abc', 'no TAB is inserted') +}) + +test('typing shift-ENTER produces a soft return', assert => { + editor = Helpers.editor.buildFromText('abc|', { element: editorElement }) + + Helpers.dom.triggerKeyCommand(editor, 'ENTER', [MODIFIERS.SHIFT]) + + assert.hasElement('#editor .-mobiledoc-kit__atom br', 'has br') +}) + +test('can unregister all handlers', assert => { + editor = Helpers.editor.buildFromText('') + // there are 3 default helpers + assert.equal(editor._eventManager._textInputHandler._handlers.length, 3) + editor.onTextInput({ + name: 'first', + match: /abc\t/, + run() {}, + }) + editor.onTextInput({ + name: 'second', + match: /abc\t/, + run() {}, + }) + assert.equal(editor._eventManager._textInputHandler._handlers.length, 5) + editor.unregisterAllTextInputHandlers() + assert.equal(editor._eventManager._textInputHandler._handlers.length, 0) +}) + +test('can unregister handler by name', assert => { + editor = Helpers.editor.buildFromText('') + const handlerName = 'ul' + let handlers = editor._eventManager._textInputHandler._handlers + assert.ok(handlers.filter(handler => handler.name === handlerName).length) + editor.unregisterTextInputHandler(handlerName) + assert.notOk(handlers.filter(handler => handler.name === handlerName).length) +}) + +test('can unregister handlers by duplicate name', assert => { + editor = Helpers.editor.buildFromText('') + const handlerName = 'ul' + editor.onTextInput({ + name: handlerName, + match: /abc/, + run() {}, + }) + let handlers = editor._eventManager._textInputHandler._handlers + assert.equal(handlers.length, 4) // 3 default + 1 custom handlers + editor.unregisterTextInputHandler(handlerName) + assert.equal(handlers.length, 2) + assert.notOk(handlers.filter(handler => handler.name === handlerName).length) +}) diff --git a/tests/helpers/assertions.ts b/tests/helpers/assertions.ts index f98876579..2cae71f62 100644 --- a/tests/helpers/assertions.ts +++ b/tests/helpers/assertions.ts @@ -6,7 +6,7 @@ import Marker from '../../src/js/models/marker' import { PostNode } from '../../src/js/models/post-node-builder' import Markup from '../../src/js/models/markup' import Post from '../../src/js/models/post' -import Atom from '../../src/js/models/atom' +import Atom from '../../src/js/models/atoms/atom' import Markuperable from '../../src/js/utils/markuperable' import ListItem from '../../src/js/models/list-item' import Card from '../../src/js/models/card' diff --git a/tests/helpers/post-abstract.ts b/tests/helpers/post-abstract.ts index 836fb7d11..80dc4bcb9 100644 --- a/tests/helpers/post-abstract.ts +++ b/tests/helpers/post-abstract.ts @@ -1,7 +1,7 @@ import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder' import Post from 'mobiledoc-kit/models/post' import { Dict, Maybe } from 'mobiledoc-kit/utils/types' -import Atom from 'mobiledoc-kit/models/atom' +import Atom from 'mobiledoc-kit/models/atoms/atom' import { keys } from 'mobiledoc-kit/utils/object-utils' import Markup from 'mobiledoc-kit/models/markup' import Markuperable from 'mobiledoc-kit/utils/markuperable' diff --git a/tests/unit/editor/key-commands-test.js b/tests/unit/editor/key-commands-test.js index ddd958338..e9db59000 100644 --- a/tests/unit/editor/key-commands-test.js +++ b/tests/unit/editor/key-commands-test.js @@ -1,146 +1,134 @@ -import { buildKeyCommand, findKeyCommands } from 'mobiledoc-kit/editor/key-commands'; -import { MODIFIERS, modifierMask as createModifierMask } from 'mobiledoc-kit/utils/key'; -import Keycodes from 'mobiledoc-kit/utils/keycodes'; +import { buildKeyCommand, findKeyCommands } from 'mobiledoc-kit/editor/key-commands' +import { MODIFIERS, modifierMask as createModifierMask } from 'mobiledoc-kit/utils/key' +import Keycodes from 'mobiledoc-kit/utils/keycodes' -import Helpers from '../../test-helpers'; +import Helpers from '../../test-helpers' -const { module, test } = Helpers; +const { module, test } = Helpers const SPECIAL_KEYS = { BACKSPACE: Keycodes.BACKSPACE, - TAB: Keycodes.TAB, - ENTER: Keycodes.ENTER, - ESC: Keycodes.ESC, - SPACE: Keycodes.SPACE, - PAGEUP: Keycodes.PAGEUP, - PAGEDOWN: Keycodes.PAGEDOWN, - END: Keycodes.END, - HOME: Keycodes.HOME, - LEFT: Keycodes.LEFT, - UP: Keycodes.UP, - RIGHT: Keycodes.RIGHT, - DOWN: Keycodes.DOWN, - INS: Keycodes.INS, - DEL: Keycodes.DELETE -}; - -module('Unit: Editor key commands'); - -test('leaves modifier, code and run in place if they exist', (assert) => { - const fn = function() {}; - - const { - modifier, code, run - } = buildKeyCommand({ + TAB: Keycodes.TAB, + ENTER: Keycodes.ENTER, + ESC: Keycodes.ESC, + SPACE: Keycodes.SPACE, + PAGEUP: Keycodes.PAGEUP, + PAGEDOWN: Keycodes.PAGEDOWN, + END: Keycodes.END, + HOME: Keycodes.HOME, + LEFT: Keycodes.LEFT, + UP: Keycodes.UP, + RIGHT: Keycodes.RIGHT, + DOWN: Keycodes.DOWN, + INS: Keycodes.INS, + DEL: Keycodes.DELETE, +} + +module('Unit: Editor key commands') + +test('leaves modifier, code and run in place if they exist', assert => { + const fn = function () {} + + const { modifier, code, run } = buildKeyCommand({ code: Keycodes.ENTER, modifier: MODIFIERS.META, - run: fn - }); + run: fn, + }) - assert.equal(modifier, MODIFIERS.META, 'keeps modifier'); - assert.equal(code, Keycodes.ENTER, 'keeps code'); - assert.equal(run, fn, 'keeps run'); -}); + assert.equal(modifier, MODIFIERS.META, 'keeps modifier') + assert.equal(code, Keycodes.ENTER, 'keeps code') + assert.equal(run, fn, 'keeps run') +}) -test('translates MODIFIER+CHARACTER string to modifierMask and code', (assert) => { +test('translates MODIFIER+CHARACTER string to modifierMask and code', assert => { + const { modifierMask, code } = buildKeyCommand({ str: 'meta+k' }) - const { modifierMask, code } = buildKeyCommand({ str: 'meta+k' }); + assert.equal(modifierMask, createModifierMask({ metaKey: true }), 'calculates correct modifierMask') + assert.equal(code, 75, 'translates string to code') +}) - assert.equal(modifierMask, createModifierMask({metaKey: true}), - 'calculates correct modifierMask'); - assert.equal(code, 75, 'translates string to code'); -}); +test('translates modifier+character string to modifierMask and code', assert => { + const { modifierMask, code } = buildKeyCommand({ str: 'META+K' }) -test('translates modifier+character string to modifierMask and code', (assert) => { + assert.equal(modifierMask, createModifierMask({ metaKey: true }), 'calculates correct modifierMask') + assert.equal(code, 75, 'translates string to code') +}) - const { modifierMask, code } = buildKeyCommand({ str: 'META+K' }); +test('translates multiple modifiers to modifierMask', assert => { + const { modifierMask, code } = buildKeyCommand({ str: 'META+SHIFT+K' }) + assert.equal(modifierMask, createModifierMask({ metaKey: true, shiftKey: true }), 'calculates correct modifierMask') + assert.equal(code, 75, 'translates string to code') +}) - assert.equal(modifierMask, createModifierMask({metaKey: true}), - 'calculates correct modifierMask'); - assert.equal(code, 75, 'translates string to code'); -}); +test('translates uppercase character string to code', assert => { + const { modifierMask, code } = buildKeyCommand({ str: 'K' }) -test('translates multiple modifiers to modifierMask', (assert) => { - const { modifierMask, code } = buildKeyCommand({ str: 'META+SHIFT+K' }); - assert.equal(modifierMask, createModifierMask({metaKey: true, shiftKey: true}), - 'calculates correct modifierMask'); - assert.equal(code, 75, 'translates string to code'); -}); + assert.equal(modifierMask, 0, 'no modifier given') + assert.equal(code, 75, 'translates string to code') +}) -test('translates uppercase character string to code', (assert) => { +test('translates lowercase character string to code', assert => { + const { modifier, code } = buildKeyCommand({ str: 'k' }) - const { modifierMask, code } = buildKeyCommand({ str: 'K' }); + assert.equal(modifier, undefined, 'no modifier given') + assert.equal(code, 75, 'translates string to code') +}) - assert.equal(modifierMask, 0, 'no modifier given'); - assert.equal(code, 75, 'translates string to code'); -}); - -test('translates lowercase character string to code', (assert) => { - - const { modifier, code } = buildKeyCommand({ str: 'k' }); - - assert.equal(modifier, undefined, 'no modifier given'); - assert.equal(code, 75, 'translates string to code'); - -}); - -test('throws when given invalid modifier', (assert) => { +test('throws when given invalid modifier', assert => { assert.throws(() => { - buildKeyCommand({str: 'MEAT+K'}); - }, /No modifier named.*MEAT.*/); -}); + buildKeyCommand({ str: 'MEAT+K' }) + }, /No modifier named.*MEAT.*/) +}) -test('throws when given `modifier` property (deprecation)', (assert) => { +test('throws when given `modifier` property (deprecation)', assert => { assert.throws(() => { - buildKeyCommand({str: 'K', modifier: MODIFIERS.META}); - }, /Key commands no longer use.*modifier.* property/); -}); + buildKeyCommand({ str: 'K', modifier: MODIFIERS.META }) + }, /Key commands no longer use.*modifier.* property/) +}) -test('throws when given str with too many characters', (assert) => { +test('throws when given str with too many characters', assert => { assert.throws(() => { - buildKeyCommand({str: 'abc'}); - }, /Only 1 character/); -}); + buildKeyCommand({ str: 'abc' }) + }, /Only 1 character/) +}) -test('translates uppercase special key names to codes', (assert) => { +test('translates uppercase special key names to codes', assert => { Object.keys(SPECIAL_KEYS).forEach(name => { - const { code } = buildKeyCommand({ str: name.toUpperCase() }); - assert.equal(code, SPECIAL_KEYS[name], `translates ${name} string to code`); - }); -}); + const { code } = buildKeyCommand({ str: name.toUpperCase() }) + assert.equal(code, SPECIAL_KEYS[name], `translates ${name} string to code`) + }) +}) -test('translates lowercase special key names to codes', (assert) => { +test('translates lowercase special key names to codes', assert => { Object.keys(SPECIAL_KEYS).forEach(name => { - const { code } = buildKeyCommand({ str: name.toLowerCase() }); - assert.equal(code, SPECIAL_KEYS[name], `translates ${name} string to code`); - }); -}); + const { code } = buildKeyCommand({ str: name.toLowerCase() }) + assert.equal(code, SPECIAL_KEYS[name], `translates ${name} string to code`) + }) +}) -test('`findKeyCommands` matches modifiers exactly', (assert) => { +test('`findKeyCommands` matches modifiers exactly', assert => { let cmdK = buildKeyCommand({ - str: 'META+K' - }); + str: 'META+K', + }) let cmdShiftK = buildKeyCommand({ - str: 'META+SHIFT+K' - }); - let commands = [cmdK, cmdShiftK]; + str: 'META+SHIFT+K', + }) + let commands = [cmdK, cmdShiftK] - let element = null; + let element = null let cmdKEvent = Helpers.dom.createMockEvent('keydown', element, { keyCode: 75, - metaKey: true - }); + metaKey: true, + }) let cmdShiftKEvent = Helpers.dom.createMockEvent('keydown', element, { keyCode: 75, metaKey: true, - shiftKey: true - }); + shiftKey: true, + }) - let found = findKeyCommands(commands, cmdKEvent); - assert.ok(found.length && found[0] === cmdK, - 'finds cmd-K command from cmd-k event'); + let found = findKeyCommands(commands, cmdKEvent) + assert.ok(found.length && found[0] === cmdK, 'finds cmd-K command from cmd-k event') - found = findKeyCommands(commands, cmdShiftKEvent); - assert.ok(found.length && found[0] === cmdShiftK, - 'finds cmd-shift-K command from cmd-shift-k event'); -}); + found = findKeyCommands(commands, cmdShiftKEvent) + assert.ok(found.length && found[0] === cmdShiftK, 'finds cmd-shift-K command from cmd-shift-k event') +})