diff --git a/src/contrib/virtual-span.js b/src/contrib/virtual-span.js new file mode 100644 index 00000000..10b42587 --- /dev/null +++ b/src/contrib/virtual-span.js @@ -0,0 +1,171 @@ +import $ from 'jquery' +import rangy from 'rangy' +import { unwrap, adoptElement } from '../content' + +function defaultMarkerConstructor (id, namespace) { + const marker = $('span')[0] + return marker +} + +const defaultOptions = { + namespace: 'vspan', + idAttribute: 'data-vspan-id', + namespaceAttribute: 'data-vspan-namespace', + editableNamespace: 'vspan', + markerConstructor: defaultMarkerConstructor, + win: undefined +} + +class VirtualSpan { + constructor ({ + namespace, + idAttribute, + namespaceAttribute, + editableNamespace, + markerConstructor, + win + } = defaultOptions) { + this.namespace = namespace + this.idAttribute = idAttribute + this.namespaceAttribute = namespaceAttribute + this.editableNamespace = editableNamespace + this.markerConstructor = markerConstructor + this.win = win + } + + getHost (host) { + return host || (this.win && this.win.document.body) + } + + extractRanges (host, markers) { + const range = rangy.createRange() + if (markers.length > 1) { + range.setStartBefore(markers.first()[0]) + range.setEndAfter(markers.last()[0]) + } else { + range.selectNode(markers[0]) + } + const textRange = range.toCharacterRange(host) + if (textRange.start > 0) textRange.start -= 1 + if (textRange.end > 0) textRange.end -= 1 + return textRange + } + + getIdSelector (id) { + return `[${this.idAttribute}="${id}"]` + } + + getIdAttrSelector () { + return `[${this.idAttribute}]` + } + + cleanOrphaned (ids, maybeHost) { + const host = this.getHost(maybeHost) + const query = $(host).find(this.getIdAttrSelector()) + ids + .reduce((q, id) => q.not(this.getIdSelector(id)), query) + .each((_, m) => unwrap(m)) + } + + createMarkerNode (id) { + let marker = this.markerConstructor(id, this.namespace) + if (this.win) { + marker = adoptElement(marker, this.win.document) + } + marker.setAttribute('data-editable', this.editableNamespace) + marker.setAttribute(this.idAttribute, id) + marker.setAttribute(this.namespaceAttribute, this.namespace) + return marker + } + + insertIntoHost (host, id, startIndex, endIndex) { + const marker = this.createMarkerNode(id, this.namespace, this.win) + const range = rangy.createRange() + range.selectCharacters(host, startIndex, endIndex) + const fragment = range.extractContents() + marker.appendChild(fragment) + range.deleteContents() + range.insertNode(marker) + } + + insert (host, id, startIndex, endIndex) { + if (this.has(id, host)) { + this.remove(id, host) + } + this.insertIntoHost(host, id, startIndex, endIndex) + } + + has (id, maybeHost) { + const host = this.getHost(maybeHost) + const matches = $(host).find(this.getIdSelector(id)) + return !!matches.length + } + + remove (id, maybeHost) { + const host = this.getHost(maybeHost) + $(host) + .find(this.getIdSelector(id)) + .each((index, elem) => { + unwrap(elem) + }) + } + + update (id, { addCssClass, removeCssClass }, maybeHost) { + if (!this.win || !this.win.document.documentElement.classList) return + const host = this.getHost(maybeHost) + $(host) + .find(this.getIdSelector(id)) + .each((index, elem) => { + if (removeCssClass) elem.classList.remove(removeCssClass) + if (addCssClass) elem.classList.add(addCssClass) + }) + } + + getData (contentStr) { + const $host = $(`