upfront-editable
Version:
Friendly contenteditable API
465 lines (412 loc) • 13.8 kB
JavaScript
import $ from 'jquery'
import rangy from 'rangy'
import * as config from './config'
import error from './util/error'
import * as parser from './parser'
import * as block from './block'
import * as content from './content'
import * as clipboard from './clipboard'
import Dispatcher from './dispatcher'
import Cursor from './cursor'
import highlightSupport from './highlight-support'
import Highlighting from './highlighting'
import createDefaultEvents from './create-default-events'
import browser from 'bowser'
/**
* The Core module provides the Editable class that defines the Editable.JS
* API and is the main entry point for Editable.JS.
* It also provides the cursor module for cross-browser cursors, and the dom
* submodule.
*
* @module core
*/
/**
* Constructor for the Editable.JS API that is externally visible.
*
* @param {Object} configuration for this editable instance.
* window: The window where to attach the editable events.
* defaultBehavior: {Boolean} Load default-behavior.js.
* mouseMoveSelectionChanges: {Boolean} Whether to get cursor and selection events on mousemove.
* browserSpellcheck: {Boolean} Set the spellcheck attribute on editable elements
*
* @class Editable
*/
const Editable = module.exports = class Editable {
constructor (instanceConfig) {
const defaultInstanceConfig = {
window: window,
defaultBehavior: true,
mouseMoveSelectionChanges: false,
browserSpellcheck: true
}
this.config = $.extend(defaultInstanceConfig, instanceConfig)
this.win = this.config.window
this.editableSelector = '.' + config.editableClass
if (!rangy.initialized) {
rangy.init()
}
this.dispatcher = new Dispatcher(this)
if (this.config.defaultBehavior === true) {
this.dispatcher.on(createDefaultEvents(this))
}
}
/**
* @returns the default Editable configs from config.js
*/
static getGlobalConfig () {
return config
}
/**
* Set configuration options that affect all editable
* instances.
*
* @param {Object} global configuration options (defaults are defined in config.js)
* log: {Boolean}
* logErrors: {Boolean}
* editableClass: {String} e.g. 'js-editable'
* editableDisabledClass: {String} e.g. 'js-editable-disabled'
* pastingAttribute: {String} default: e.g. 'data-editable-is-pasting'
* boldTag: e.g. '<strong>'
* italicTag: e.g. '<em>'
*/
static globalConfig (globalConfig) {
$.extend(config, globalConfig)
clipboard.updateConfig(config)
}
/**
* Adds the Editable.JS API to the given target elements.
* Opposite of {{#crossLink "Editable/remove"}}{{/crossLink}}.
* Calls dispatcher.setup to setup all event listeners.
*
* @method add
* @param {HTMLElement|Array(HTMLElement)|String} target A HTMLElement, an
* array of HTMLElement or a query selector representing the target where
* the API should be added on.
* @chainable
*/
add (target) {
this.enable($(target))
// todo: check css whitespace settings
return this
}
/**
* Removes the Editable.JS API from the given target elements.
* Opposite of {{#crossLink "Editable/add"}}{{/crossLink}}.
*
* @method remove
* @param {HTMLElement|Array(HTMLElement)|String} target A HTMLElement, an
* array of HTMLElement or a query selector representing the target where
* the API should be removed from.
* @chainable
*/
remove (target) {
const $target = $(target)
this.disable($target)
$target.removeClass(config.editableDisabledClass)
return this
}
/**
* Removes the Editable.JS API from the given target elements.
* The target elements are marked as disabled.
*
* @method disable
* @param { jQuery element | undefined } target editable root element(s)
* If no param is specified all editables are disabled.
* @chainable
*/
disable ($elem) {
const body = this.win.document.body
$elem = $elem || $('.' + config.editableClass, body)
$elem.each((i, el) => block.disable(el))
return this
}
/**
* Adds the Editable.JS API to the given target elements.
*
* @method enable
* @param { jQuery element | undefined } target editable root element(s)
* If no param is specified all editables marked as disabled are enabled.
* @chainable
*/
enable ($elem, normalize) {
const body = this.win.document.body
$elem = $elem || $('.' + config.editableDisabledClass, body)
const shouldSpellcheck = this.config.browserSpellcheck
$elem.each((i, el) => {
block.init(el, {normalize, shouldSpellcheck})
this.dispatcher.notify('init', el)
})
return this
}
/**
* Temporarily disable an editable.
* Can be used to prevent text selction while dragging an element
* for example.
*
* @method suspend
* @param jQuery object
*/
suspend ($elem) {
const body = this.win.document.body
$elem = $elem || $('.' + config.editableClass, body)
$elem.removeAttr('contenteditable')
this.dispatcher.suspend()
return this
}
/**
* Reverse the effects of suspend()
*
* @method continue
* @param jQuery object
*/
continue ($elem) {
const body = this.win.document.body
$elem = $elem || $('.' + config.editableClass, body)
$elem.attr('contenteditable', true)
this.dispatcher.continue()
return this
}
/**
* Set the cursor inside of an editable block.
*
* @method createCursor
* @param position 'beginning', 'end', 'before', 'after'
*/
createCursor (element, position = 'beginning') {
const $host = $(element).closest(this.editableSelector)
if (!$host.length) return undefined
const range = rangy.createRange()
if (position === 'beginning' || position === 'end') {
range.selectNodeContents(element)
range.collapse(position === 'beginning')
} else if (element !== $host[0]) {
if (position === 'before') {
range.setStartBefore(element)
range.setEndBefore(element)
} else if (position === 'after') {
range.setStartAfter(element)
range.setEndAfter(element)
}
} else {
error('EditableJS: cannot create cursor outside of an editable block.')
}
return new Cursor($host[0], range)
}
createCursorAtBeginning (element) {
return this.createCursor(element, 'beginning')
}
createCursorAtEnd (element) {
return this.createCursor(element, 'end')
}
createCursorBefore (element) {
return this.createCursor(element, 'before')
}
createCursorAfter (element) {
return this.createCursor(element, 'after')
}
/**
* Extract the content from an editable host or document fragment.
* This method will remove all internal elements and ui-elements.
*
* @param {DOM node or Document Fragment} The innerHTML of this element or fragment will be extracted.
* @returns {String} The cleaned innerHTML.
*/
getContent (element) {
return content.extractContent(element)
}
/**
* @param {String | DocumentFragment} content to append.
* @returns {Cursor} A new Cursor object just before the inserted content.
*/
appendTo (inputElement, contentToAppend) {
const element = content.adoptElement(inputElement, this.win.document)
const cursor = this.createCursor(element, 'end')
// todo: create content in the right window
cursor.insertAfter(typeof contentToAppend === 'string'
? content.createFragmentFromString(contentToAppend)
: contentToAppend
)
return cursor
}
/**
* @param {String | DocumentFragment} content to prepend
* @returns {Cursor} A new Cursor object just after the inserted content.
*/
prependTo (inputElement, contentToPrepend) {
const element = content.adoptElement(inputElement, this.win.document)
const cursor = this.createCursor(element, 'beginning')
// todo: create content in the right window
cursor.insertBefore(typeof contentToPrepend === 'string'
? content.createFragmentFromString(contentToPrepend)
: contentToPrepend
)
return cursor
}
/**
* Get the current selection.
* Only returns something if the selection is within an editable element.
* If you pass an editable host as param it only returns something if the selection is inside this
* very editable element.
*
* @param {DOMNode} Optional. An editable host where the selection needs to be contained.
* @returns A Cursor or Selection object or undefined.
*/
getSelection (editableHost) {
let selection = this.dispatcher.selectionWatcher.getFreshSelection()
if (!(editableHost && selection)) return selection
var range = selection.range
// Check if the selection is inside the editableHost
// The try...catch is required if the editableHost was removed from the DOM.
try {
if (range.compareNode(editableHost) !== range.NODE_BEFORE_AND_AFTER) {
selection = undefined
}
} catch (e) {
selection = undefined
}
return selection
}
/**
* Enable spellchecking
*
* @chainable
*/
setupHighlighting (hightlightingConfig) {
this.highlighting = new Highlighting(this, hightlightingConfig)
return this
}
// For backwards compatibility
setupSpellcheck (config) {
let marker
if (config.markerNode) {
marker = config.markerNode.outerHTML
}
this.setupHighlighting({
throttle: config.throttle,
spellcheck: {
marker: marker,
spellcheckService: config.spellcheckService
}
})
this.spellcheck = {
checkSpelling: (elem) => {
this.highlighting.highlight(elem)
}
}
}
/**
* Highlight text within an editable.
*
* By default highlights all occurences of `text`.
* Pass it a `textRange` object to highlight a
* specific text portion.
*
* The markup used for the highlighting will be removed
* from the final content.
*
*
* @param {Object} options
* @param {DOMNode} options.editableHost
* @param {String} options.text
* @param {String} options.highlightId Added to the highlight markups in the property `data-word-id`
* @param {Object} [options.textRange] An optional range which gets used to set the markers.
* @param {Number} options.textRange.start
* @param {Number} options.textRange.end
* @param {Boolean} options.raiseEvents do throw change events
* @return {Number} The text-based start offset of the newly applied highlight or `-1` if the range was considered invalid.
*/
highlight ({editableHost, text, highlightId, textRange, raiseEvents}) {
if (!textRange) {
return highlightSupport.highlightText(editableHost, text, highlightId)
}
if (typeof textRange.start !== 'number' || typeof textRange.end !== 'number') {
error(
'Error in Editable.highlight: You passed a textRange object with invalid keys. Expected shape: { start: Number, end: Number }'
)
return -1
}
if (textRange.start === textRange.end) {
error(
'Error in Editable.highlight: You passed a textRange object with equal start and end offsets, which is considered a cursor and therefore unfit to create a highlight.'
)
return -1
}
return highlightSupport.highlightRange(editableHost, highlightId, textRange.start, textRange.end, raiseEvents ? this.dispatcher : undefined)
}
/**
* Extracts positions of all DOMNodes that match `[data-word-id]`.
*
* Returns an object where the keys represent a highlight id and the value
* a text range object of shape:
* ```
* { start: number, end: number, text: string}
* ```
*
* @param {Object} options
* @param {DOMNode} options.editableHos
* @return {Object} ranges
*/
getHighlightPositions ({ editableHost }) {
return highlightSupport.extractHighlightedRanges(
editableHost
)
}
removeHighlight ({editableHost, highlightId, raiseEvents}) {
highlightSupport.removeHighlight(editableHost, highlightId, raiseEvents ? this.dispatcher : undefined)
}
decorateHighlight ({editableHost, highlightId, addCssClass, removeCssClass}) {
highlightSupport.updateHighlight(editableHost, highlightId, addCssClass, removeCssClass)
}
/**
* Subscribe a callback function to a custom event fired by the API.
*
* @param {String} event The name of the event.
* @param {Function} handler The callback to execute in response to the
* event.
*
* @chainable
*/
on (event, handler) {
// TODO throw error if event is not one of EVENTS
// TODO throw error if handler is not a function
this.dispatcher.on(event, handler)
return this
}
/**
* Unsubscribe a callback function from a custom event fired by the API.
* Opposite of {{#crossLink "Editable/on"}}{{/crossLink}}.
*
* @param {String} event The name of the event.
* @param {Function} handler The callback to remove from the
* event or the special value false to remove all callbacks.
*
* @chainable
*/
off (...args) {
this.dispatcher.off.apply(this.dispatcher, args)
return this
}
/**
* Unsubscribe all callbacks and event listeners.
*
* @chainable
*/
unload () {
this.dispatcher.unload()
return this
}
}
// Expose modules and editable
Editable.parser = parser
Editable.content = content
Editable.browser = browser
// Set up callback functions for several events.
;['focus', 'blur', 'flow', 'selection', 'cursor', 'newline',
'insert', 'split', 'merge', 'empty', 'change', 'switch',
'move', 'clipboard', 'paste'
].forEach((name) => {
// Generate a callback function to subscribe to an event.
Editable.prototype[name] = function (handler) {
return this.on(name, handler)
}
})