Debugging tools:
230 | Dump: 231 | 232 | 233 | 234 | 235 | 236 | 237 |and & chars.', attrs: {}} 36 | ] 37 | assert.equal(writeHtml(chunks), 'Text with <p> and & chars.') 38 | }) 39 | 40 | it('outputs styled text correctly', () => { 41 | let chunks = [ 42 | {'text': 'bold', 'attrs': {bold: true}}, 43 | {'text': ' italic', 'attrs': {italic: true}}, 44 | {'text': ' underline', 'attrs': {underline: true}}, 45 | {'text': ' superscript', 'attrs': {'superscript': true}}, 46 | {'text': ' subscript', 'attrs': {'subscript': true}}, 47 | {'text': ' strikethrough', 'attrs': {'strikethrough': true}}, 48 | {'text': '.', 'attrs': {}} 49 | ] 50 | assert.equal(writeHtml(chunks), 'bold italic underline superscript subscript strikethrough.') 51 | }) 52 | 53 | it('outputs a single space as a normal space', () => { 54 | let chunks = [ 55 | {'text': ' ', 'attrs': {}} 56 | ] 57 | assert.equal(writeHtml(chunks), ' ') 58 | }) 59 | 60 | it('outputs a non-breaking space with a non-breaking space entity', () => { 61 | let chunks = [ 62 | {'text': '\xA0', 'attrs': {}} 63 | ] 64 | assert.equal(writeHtml(chunks), ' ') 65 | }) 66 | 67 | it('outputs complex styled text correctly', () => { 68 | let chunks = [ 69 | {'text': 'Text with', 'attrs': {}}, 70 | {'text': ' ', 'attrs': {}}, 71 | {'text': 'superscript mixed with', 'attrs': {'superscript': true}}, 72 | {'text': ' ', 'attrs': {'superscript': true}}, 73 | {'text': 'bold', 'attrs': {'bold': true, 'superscript': true}}, 74 | {'text': ' ', 'attrs': {'superscript': true}}, 75 | {'text': 'and', 'attrs': {'superscript': true}}, 76 | {'text': ' ', 'attrs': {'superscript': true}}, 77 | {'text': 'italic', 'attrs': {'italic': true, 'superscript': true}}, 78 | {'text': ' ', 'attrs': {'superscript': true}}, 79 | {'text': 'and', 'attrs': {'superscript': true}}, 80 | {'text': ' ', 'attrs': {'superscript': true}}, 81 | {'text': 'underline', 'attrs': {'superscript': true, 'underline': true}}, 82 | {'text': '.', 'attrs': {}} 83 | ] 84 | assert.equal(writeHtml(chunks), 'Text with superscript mixed with bold and italic and underline.') 85 | }) 86 | 87 | it('outputs styled paragraphs when there is only one chunk between the newlines', () => { 88 | let chunks = [ 89 | {text: 'Line 1.', attrs: {bold: true}}, 90 | {text: '\n', attrs: {}}, 91 | {text: '\n', attrs: {}}, 92 | {text: 'Line 2.', attrs: {italic: true}} 93 | ] 94 | assert.equal(writeHtml(chunks), '
Line 1.
Line 2.
') 95 | }) 96 | 97 | it('outputs styled spans inside paragraphs when there are multiple chunks between the newlines', () => { 98 | let chunks = [ 99 | {text: 'Line 1 Part 1.', attrs: {bold: true}}, 100 | {text: 'Line 1 Part 2.', attrs: {bold: true}}, 101 | {text: '\n', attrs: {}}, 102 | {text: '\n', attrs: {}}, 103 | {text: 'Line 2.', attrs: {italic: true}} 104 | ] 105 | assert.equal(writeHtml(chunks), 'Line 1 Part 1.Line 1 Part 2.
Line 2.
') 106 | }) 107 | 108 | it('outputs breaks when there is one newline', () => { 109 | let chunks = [ 110 | {text: 'Line 1.', attrs: {}}, 111 | {text: '\n', attrs: {}}, 112 | {text: 'Line 2.', attrs: {}} 113 | ] 114 | assert.equal(writeHtml(chunks), 'Line 1.Line 1.
Line 2.
') 126 | }) 127 | 128 | it('outputs paragraphs when there are two newlines', () => { 129 | let chunks = [ 130 | {text: 'Line 1.', attrs: {}}, 131 | {text: '\n', attrs: {}}, 132 | {text: '\n', attrs: {}}, 133 | {text: 'Line 2.', attrs: {}} 134 | ] 135 | assert.equal(writeHtml(chunks), 'Line 1.
Line 2.
') 136 | }) 137 | 138 | it('outputs styled paragraphs when there are two newlines and spans/breaks with one', () => { 139 | let chunks = [ 140 | {text: 'Line 1.', attrs: {}}, 141 | {text: '\n', attrs: {}}, 142 | {text: '\n', attrs: {}}, 143 | {text: 'Line 2.', attrs: {}}, 144 | {text: '\n', attrs: {}}, 145 | {text: 'Line 3.', attrs: {}} 146 | ] 147 | assert.equal(writeHtml(chunks), 'Line 1.
Line 2.Line 3.
') 148 | }) 149 | 150 | it('outputs breaks when there are two newlines at the end', () => { 151 | let chunks = [ 152 | {text: 'Line 1.', attrs: {}}, 153 | {text: '\n', attrs: {}}, 154 | {text: '\n', attrs: {}} 155 | ] 156 | assert.equal(writeHtml(chunks), 'Line 1.
Line 1.
and & chars.', attrs: {}} 33 | ] 34 | assert.equal(writeText(chunks), 'Text with
and & chars.') 35 | }) 36 | 37 | it('ignores text styles', () => { 38 | let chunks = [ 39 | {'text': 'bold', 'attrs': {bold: true}}, 40 | {'text': ' italic', 'attrs': {italic: true}}, 41 | {'text': ' underline', 'attrs': {underline: true}}, 42 | {'text': ' superscript', 'attrs': {'superscript': true}}, 43 | {'text': ' subscript', 'attrs': {'subscript': true}}, 44 | {'text': ' strikethrough', 'attrs': {'strikethrough': true}}, 45 | {'text': '.', 'attrs': {}} 46 | ] 47 | assert.equal(writeText(chunks), 'bold italic underline superscript subscript strikethrough.') 48 | }) 49 | 50 | it('outputs a single space as a normal space', () => { 51 | let chunks = [ 52 | {'text': ' ', 'attrs': {}} 53 | ] 54 | assert.equal(writeText(chunks), ' ') 55 | }) 56 | 57 | it('outputs a non-breaking space as a non-breaking space', () => { 58 | let chunks = [ 59 | {'text': '\xA0', 'attrs': {}} 60 | ] 61 | assert.equal(writeText(chunks), '\xA0') 62 | }) 63 | 64 | it('outputs newlines as a newline', () => { 65 | let chunks = [ 66 | {text: 'Line 1.', attrs: {bold: true}}, 67 | {text: '\n', attrs: {}}, 68 | {text: 'Line 2.', attrs: {italic: true}} 69 | ] 70 | assert.equal(writeText(chunks), 'Line 1.\nLine 2.') 71 | }) 72 | 73 | it('outputs paragraph breaks as two newlines', () => { 74 | let chunks = [ 75 | {text: 'Line 1.', attrs: {bold: true}}, 76 | {text: '\n', attrs: {}}, 77 | {text: '\n', attrs: {}}, 78 | {text: 'Line 2.', attrs: {italic: true}} 79 | ] 80 | assert.equal(writeText(chunks), 'Line 1.\n\nLine 2.') 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/core/__tests__/tokenizer-test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import _ from 'lodash' 3 | import tokenizer from '../tokenizer' 4 | 5 | const rangeSub_ = function(text, ranges) { 6 | return _.partial((t, r, index) => { 7 | let range = r[index] 8 | return t.substring(range.start, range.end) 9 | }, text, ranges) 10 | } 11 | 12 | describe('word tokenizer', () => { 13 | it('tokenizes words with trailing spaces', () => { 14 | let text = 'A few words, hy-phen-ated too.' 15 | let ranges = tokenizer(text) 16 | let sub = rangeSub_(text, ranges) 17 | 18 | assert.equal(ranges.length, 7) 19 | assert.equal(sub(0), 'A ') 20 | assert.equal(sub(1), 'few ') 21 | assert.equal(sub(2), 'words') 22 | assert.equal(sub(3), ', ') 23 | assert.equal(sub(4), 'hy-phen-ated ') 24 | assert.equal(sub(5), 'too') 25 | assert.equal(sub(6), '.') 26 | 27 | assert.equal(true, ranges[0].isWord) 28 | assert.equal(true, ranges[1].isWord) 29 | assert.equal(true, ranges[2].isWord) 30 | assert.equal(false, ranges[3].isWord) 31 | assert.equal(true, ranges[4].isWord) 32 | assert.equal(true, ranges[5].isWord) 33 | assert.equal(false, ranges[6].isWord) 34 | }) 35 | 36 | it('tokenizes words without trailing spaces', () => { 37 | let text = 'A few words, hy-phen-ated too.' 38 | let ranges = tokenizer(text, { includeTrailingSpace: false }) 39 | let sub = rangeSub_(text, ranges) 40 | 41 | assert.equal(ranges.length, 10) 42 | assert.equal(sub(0), 'A') 43 | assert.equal(sub(1), ' ') 44 | assert.equal(sub(2), 'few') 45 | assert.equal(sub(3), ' ') 46 | assert.equal(sub(4), 'words') 47 | assert.equal(sub(5), ', ') 48 | assert.equal(sub(6), 'hy-phen-ated') 49 | assert.equal(sub(7), ' ') 50 | assert.equal(sub(8), 'too') 51 | assert.equal(sub(9), '.') 52 | 53 | assert.equal(true, ranges[0].isWord) 54 | assert.equal(false, ranges[1].isWord) 55 | assert.equal(true, ranges[2].isWord) 56 | assert.equal(false, ranges[3].isWord) 57 | assert.equal(true, ranges[4].isWord) 58 | assert.equal(false, ranges[5].isWord) 59 | assert.equal(true, ranges[6].isWord) 60 | assert.equal(false, ranges[7].isWord) 61 | assert.equal(true, ranges[8].isWord) 62 | assert.equal(false, ranges[9].isWord) 63 | }) 64 | 65 | it('tokenizes words with leading spaces', () => { 66 | let text = 'A few words, hy-phen-ated too.' 67 | let ranges = tokenizer(text, { includeLeadingSpace: true }) 68 | let sub = rangeSub_(text, ranges) 69 | 70 | assert.equal(ranges.length, 7) 71 | assert.equal(sub(0), 'A') 72 | assert.equal(sub(1), ' few') 73 | assert.equal(sub(2), ' words') 74 | assert.equal(sub(3), ',') 75 | assert.equal(sub(4), ' hy-phen-ated') 76 | assert.equal(sub(5), ' too') 77 | assert.equal(sub(6), '.') 78 | 79 | assert.equal(true, ranges[0].isWord) 80 | assert.equal(true, ranges[1].isWord) 81 | assert.equal(true, ranges[2].isWord) 82 | assert.equal(false, ranges[3].isWord) 83 | assert.equal(true, ranges[4].isWord) 84 | assert.equal(true, ranges[5].isWord) 85 | assert.equal(false, ranges[6].isWord) 86 | }) 87 | 88 | it('tokenizes words with both leading and trailing spaces', () => { 89 | let text = 'A few words, hy-phen-ated too.' 90 | let ranges = tokenizer(text, { includeLeadingSpace: true, includeTrailingSpace: true }) 91 | let sub = rangeSub_(text, ranges) 92 | 93 | //assert.equal(ranges.length, 7) 94 | assert.equal(sub(0), 'A ') 95 | assert.equal(sub(1), ' few ') 96 | assert.equal(sub(2), ' words') 97 | assert.equal(sub(3), ',') 98 | assert.equal(sub(4), ' hy-phen-ated ') 99 | assert.equal(sub(5), ' too') 100 | assert.equal(sub(6), '.') 101 | 102 | assert.equal(true, ranges[0].isWord) 103 | assert.equal(true, ranges[1].isWord) 104 | assert.equal(true, ranges[2].isWord) 105 | assert.equal(false, ranges[3].isWord) 106 | assert.equal(true, ranges[4].isWord) 107 | assert.equal(true, ranges[5].isWord) 108 | assert.equal(false, ranges[6].isWord) 109 | }) 110 | 111 | it('tokenizes newlines as words', () => { 112 | let text = 'A few \nwords,\nwith newlines.\n' 113 | let ranges = tokenizer(text) 114 | let sub = rangeSub_(text, ranges) 115 | 116 | //assert.equal(ranges.length, 7) 117 | assert.equal(sub(0), 'A ') 118 | assert.equal(sub(1), 'few ') 119 | assert.equal(sub(2), '\n') 120 | assert.equal(sub(3), 'words') 121 | assert.equal(sub(4), ',') 122 | assert.equal(sub(5), '\n') 123 | assert.equal(sub(6), 'with ') 124 | assert.equal(sub(7), 'newlines') 125 | assert.equal(sub(8), '.') 126 | assert.equal(sub(9), '\n') 127 | 128 | assert.equal(true, ranges[0].isWord) 129 | assert.equal(true, ranges[1].isWord) 130 | assert.equal(true, ranges[2].isWord) 131 | assert.equal(true, ranges[3].isWord) 132 | assert.equal(false, ranges[4].isWord) 133 | assert.equal(true, ranges[5].isWord) 134 | assert.equal(true, ranges[6].isWord) 135 | assert.equal(true, ranges[7].isWord) 136 | assert.equal(false, ranges[8].isWord) 137 | assert.equal(true, ranges[9].isWord) 138 | }) 139 | 140 | }) 141 | -------------------------------------------------------------------------------- /src/core/alt.js: -------------------------------------------------------------------------------- 1 | // weird, this should work but doesn't 2 | //import Alt from 'alt' 3 | import Alt from 'alt/lib/index' 4 | 5 | let alt = new Alt() 6 | 7 | //if(__DEV__) { 8 | // let chromeDebug = require('alt/utils/chromeDebug') 9 | // chromeDebug(alt) 10 | //} 11 | 12 | export default alt 13 | -------------------------------------------------------------------------------- /src/core/attributes.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export const ATTR = { 4 | BOLD: 'bold', 5 | ITALIC: 'italic', 6 | UNDERLINE: 'underline', 7 | STRIKETHROUGH: 'strikethrough', 8 | SUPERSCRIPT: 'superscript', 9 | SUBSCRIPT: 'subscript' 10 | } 11 | 12 | function hasAttribute(attributes, attr) { 13 | return attributes && attributes[attr] 14 | } 15 | 16 | export function hasAttributeFor(attributes) { 17 | return _.partial(hasAttribute, attributes) 18 | } 19 | 20 | export function attributesEqual(attr1, attr2) { 21 | let normalize = a => _.pick(a, value => value) // pick entries where value exists and is truthy 22 | return _.isEqual(normalize(attr1), normalize(attr2)) 23 | } 24 | -------------------------------------------------------------------------------- /src/core/dom.js: -------------------------------------------------------------------------------- 1 | export function getNumericStyleProperty(style, prop) { 2 | return parseInt(style.getPropertyValue(prop), 10) 3 | } 4 | 5 | export function getPixelStyleProperty(style, prop) { 6 | return Number(style.getPropertyValue(prop).match(/(\d*(\.\d*)?)px/)[1]) 7 | } 8 | 9 | /** 10 | * The computed font-weight property is textual ("bold") on some browsers e.g. Chrome and numeric Strings ("700") 11 | * on some other browsers e.g. Firefox. This method normalizes the font-weight value to a textual property. 12 | * See https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight. 13 | * 14 | * This method does not do a complete normalization e.g. lighter, bolder, etc. Only basic normal and bold weights 15 | * are currently handled. 16 | * @param fontWeight 17 | */ 18 | export function normalizeFontWeight(fontWeight) { 19 | let fontWeightNumeric = parseInt(fontWeight, 10) 20 | if (Number.isNaN(fontWeightNumeric)) { 21 | return fontWeight 22 | } else if (fontWeightNumeric === 400) { 23 | return 'normal' 24 | } else if (fontWeightNumeric === 700) { 25 | return 'bold' 26 | } else { 27 | return fontWeight 28 | } 29 | } 30 | 31 | /** 32 | * Returns the currently set browser minimum font size. We create an invisible element and set its font-size 33 | * style to 1px. We then obtain the browser's computed font-size property, which should be the minimum size 34 | * allowed by the browser's current settings. TODO is there a better way to get the browser minimum font size? 35 | */ 36 | export function detectMinFontSize() { 37 | let elem = document.createElement('div') 38 | elem.style['font-size'] = '1px' 39 | elem.style.display = 'none' 40 | elem.style.visibility = 'hidden' 41 | document.body.appendChild(elem) 42 | let style = getComputedStyle(elem, null) 43 | let size = getPixelStyleProperty(style, 'font-size') 44 | document.body.removeChild(elem) 45 | return size 46 | } 47 | 48 | /** 49 | * Returns the position of an element relative to the page, or until a parent element for which the provided 50 | * 'until' function is truthy. Basic implementation from http://stackoverflow.com/a/5776220/430128. 51 | * @param elem 52 | * @param until A function, if provided, is passed each parent element and computed style. If it returns a 53 | * truthy value, the returned position will be relative to that element. 54 | * @returns {{x: number, y: number}} 55 | */ 56 | export function elementPosition(elem, until) { 57 | let x = 0 58 | let y = 0 59 | let inner = true 60 | 61 | while (elem) { 62 | let style = getComputedStyle(elem, null) 63 | if(until && until(elem, style)) break 64 | x += elem.offsetLeft 65 | y += elem.offsetTop 66 | y += getNumericStyleProperty(style, 'border-top-width') 67 | x += getNumericStyleProperty(style, 'border-left-width') 68 | if (inner) { 69 | y += getNumericStyleProperty(style, 'padding-top') 70 | x += getNumericStyleProperty(style, 'padding-left') 71 | } 72 | inner = false 73 | elem = elem.offsetParent 74 | } 75 | return {x: x, y: y} 76 | } 77 | 78 | /** 79 | * Returns the number of pixels to scroll the window by (in the x and y directions) to make an element completely 80 | * visible within the viewport. 81 | * @param el The element 82 | * @param xGutter 83 | * @param yGutter 84 | * @return {{xDelta: number, yDelta: number}} 85 | */ 86 | export function scrollByToVisible(el, xGutter, yGutter) { 87 | let rect = el.getBoundingClientRect() 88 | let xDelta = 0 89 | let yDelta = 0 90 | xGutter = xGutter || 0 91 | yGutter = yGutter || 0 92 | 93 | let windowWidth = document.documentElement.clientWidth || document.body.clientWidth 94 | if(rect.right < xGutter) xDelta = rect.right - xGutter 95 | else if(rect.left > windowWidth - xGutter) xDelta = rect.left - windowWidth + xGutter 96 | 97 | let windowHeight = document.documentElement.clientHeight || document.body.clientHeight 98 | if(rect.top < yGutter) yDelta = rect.top - yGutter 99 | else if(rect.bottom > windowHeight - yGutter) yDelta = rect.bottom - windowHeight + yGutter 100 | 101 | return { 102 | xDelta: xDelta, 103 | yDelta: yDelta 104 | } 105 | } 106 | 107 | /** 108 | * Empties a DOM node of all its children. 109 | * @param {Node} node 110 | */ 111 | export function emptyNode(node) { 112 | while (node.firstChild) { 113 | node.removeChild(node.firstChild) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/core/htmlwriter.js: -------------------------------------------------------------------------------- 1 | import {ATTR, hasAttributeFor} from './attributes' 2 | import classNames from 'classnames' 3 | import CSSPropertyOperations from 'react/lib/CSSPropertyOperations' 4 | 5 | function styleForAttributes(attributes) { 6 | let hasAttribute = hasAttributeFor(attributes) 7 | 8 | let style = {} 9 | let superscript = hasAttribute(ATTR.SUPERSCRIPT) 10 | let subscript = hasAttribute(ATTR.SUBSCRIPT) 11 | if(superscript || subscript) { 12 | style.verticalAlign = classNames({ 13 | super: superscript, 14 | sub: subscript 15 | }) 16 | } 17 | 18 | // font size, weight, style 19 | //let fontSize = this.fontSizeFromAttributes(this.props.fontSize, attributes) 20 | 21 | if(hasAttribute(ATTR.BOLD)) { 22 | style.fontWeight = 'bold' 23 | } 24 | if(hasAttribute(ATTR.ITALIC)) { 25 | style.fontStyle = 'italic' 26 | } 27 | 28 | // text-decoration 29 | let underline = hasAttribute(ATTR.UNDERLINE) 30 | let strikethrough = hasAttribute(ATTR.STRIKETHROUGH) 31 | 32 | if(underline || strikethrough) { 33 | style.textDecoration = classNames({ 34 | underline: underline, 35 | 'line-through': strikethrough 36 | }) 37 | } 38 | 39 | return style 40 | } 41 | 42 | function setStyle(el, style, preserveWhitespace) { 43 | if (preserveWhitespace) { 44 | style.whiteSpace = 'pre-wrap' 45 | } 46 | let cssString = CSSPropertyOperations.createMarkupForStyles(style) 47 | el.setAttribute('style', cssString) 48 | } 49 | 50 | /** 51 | * Writes chunks of rich text into an HTML document. 52 | * 53 | * @param {Array} chunks The rich text chunks to write. 54 | */ 55 | export function writeHtmlAsDom(chunks) { 56 | let html = document.createDocumentFragment() 57 | 58 | if(!chunks) { 59 | chunks = [] 60 | } 61 | 62 | let pendingChunks = [] 63 | 64 | let pushToHtml = fragment => { 65 | html.appendChild(fragment) 66 | } 67 | 68 | let createSpan = chunk => { 69 | let textNode = document.createTextNode(chunk.text) 70 | let span = document.createElement('SPAN') 71 | span.appendChild(textNode) 72 | setStyle(span, styleForAttributes(chunk.attrs), true) 73 | return span 74 | } 75 | 76 | let pushBreakToHtml = () => { 77 | pushToHtml(document.createElement('BR')) 78 | } 79 | 80 | let pushSpansToHtml = () => { 81 | if(pendingChunks.length === 0) { 82 | return 83 | } 84 | let spans = pendingChunks.map(createSpan) 85 | let fragment = document.createDocumentFragment() 86 | spans.forEach(s => fragment.appendChild(s)) 87 | pushToHtml(fragment) 88 | pendingChunks = [] 89 | } 90 | 91 | let pushParaToHtml = () => { 92 | if(pendingChunks.length === 0) { 93 | return 94 | } 95 | let para = document.createElement('P') 96 | if(pendingChunks.length > 1) { 97 | // encapsulate chunks in styled spans 98 | let spans = pendingChunks.map(createSpan) 99 | spans.forEach(s => para.appendChild(s)) 100 | } else if(pendingChunks.length === 1) { 101 | // encapsulate chunk in styled para 102 | let textNode = document.createTextNode(pendingChunks[0].text) 103 | para.appendChild(textNode) 104 | setStyle(para, styleForAttributes(pendingChunks[0].attrs), true) 105 | } 106 | pushToHtml(para) 107 | pendingChunks = [] 108 | } 109 | 110 | let newlineCount = 0 111 | let paraClean = true 112 | 113 | let handleNewlines = (atEnd) => { 114 | if(newlineCount > 0) { 115 | if(newlineCount >= 2) { 116 | // a bunch of newlines in a row, we need to push a paragraph for the first two and assume the rest are breaks 117 | // see http://www.w3.org/TR/html5/dom.html#palpable-content 118 | pushParaToHtml(pendingChunks) 119 | paraClean = false 120 | if(atEnd) { 121 | // add line breaks too 122 | while(newlineCount > 0) { 123 | pushBreakToHtml() 124 | newlineCount-- 125 | } 126 | } else { 127 | newlineCount -= 2 128 | } 129 | } 130 | // treat any more newlines as line breaks 131 | while(newlineCount > 0) { 132 | pushSpansToHtml(pendingChunks) 133 | pushBreakToHtml() 134 | newlineCount-- 135 | } 136 | } 137 | } 138 | 139 | chunks.forEach(c => { 140 | if(c.text === '\n') { 141 | newlineCount++ 142 | } else { 143 | handleNewlines(false) 144 | pendingChunks.push(c) 145 | } 146 | }) 147 | 148 | // trailing newlines 149 | handleNewlines(true) 150 | 151 | if(paraClean) { 152 | pushSpansToHtml(pendingChunks) 153 | } else { 154 | pushParaToHtml(pendingChunks) 155 | } 156 | 157 | return html 158 | } 159 | 160 | /** 161 | * Writes chunks of rich text into an HTML document. 162 | * 163 | * @param {Array} chunks The rich text chunks to write. 164 | */ 165 | export default function writeHtml(chunks) { 166 | let html = writeHtmlAsDom(chunks) 167 | 168 | // fragments don't have an innerHTML method so we need to wrap it in another container first 169 | let container = document.createElement('div') 170 | container.appendChild(html) 171 | return container.innerHTML 172 | } 173 | -------------------------------------------------------------------------------- /src/core/replica.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a Swarm spec and returns its data as an object. 3 | * @param spec 4 | * @returns {{source: *, op: *}} 5 | */ 6 | export function parseSpec(spec) { 7 | // spec seems to have some internal parsing state "index" which prevents accessing it consistently 8 | // https://github.com/gritzko/swarm/issues/53 9 | let oldIndex = spec.index 10 | spec.index = 0 11 | let source 12 | try { 13 | source = spec.source() 14 | } catch (e) { 15 | source = null 16 | } 17 | let op 18 | try { 19 | op = spec.op() 20 | } catch (e) { 21 | op = null 22 | } 23 | spec.index = oldIndex 24 | return { 25 | source: source, 26 | op: op 27 | } 28 | } 29 | 30 | /** 31 | * Returns the source from within a Swarm spec. 32 | * @param spec 33 | * @returns {*} 34 | */ 35 | export function sourceOf(spec) { 36 | return parseSpec(spec).source 37 | } 38 | -------------------------------------------------------------------------------- /src/core/swarmclient.js: -------------------------------------------------------------------------------- 1 | import SwarmBase from 'swarm' 2 | import swarmFactory from './swarmfactory' 3 | 4 | export default class SwarmClient { 5 | constructor(localUser, config) { 6 | let Swarm = swarmFactory(SwarmBase) 7 | this.Swarm = Swarm 8 | 9 | this.id = localUser 10 | 11 | // server host uri (/websocket is appended because https://github.com/websockets/ws/issues/131) 12 | let windowLocation = window.location.hostname 13 | let port 14 | if(config && config.wsPort) { 15 | port = config.wsPort 16 | } else { 17 | port = window.location.port 18 | } 19 | windowLocation = windowLocation + ':' + port 20 | this.wsServerUri = 'ws://' + windowLocation + '/websocket' 21 | 22 | let hash = window.location.hash || '#0' 23 | 24 | // create Host 25 | this.host = Swarm.env.localhost = new Swarm.Host(this.id + hash.replace('#', '~')) 26 | 27 | // connect to server 28 | this.pipe = this.host.connect(this.wsServerUri, {delay: -1}) 29 | 30 | this.reonHooks = [] 31 | this.unloadHooks = [] 32 | 33 | //catch online/offline status changes 34 | this.host.on('reon', (spec, val) => { // eslint-disable-line no-unused-vars 35 | document.body.setAttribute('connected', this.host.isUplinked()) 36 | for(let i = 0; i < this.reonHooks.length; i++) { 37 | try { 38 | this.reonHooks[i]() 39 | } catch (e) { 40 | console.warn('Swarm reon hook failed.', e) 41 | } 42 | } 43 | }) 44 | this.host.on('reoff', (spec, val) => { // eslint-disable-line no-unused-vars 45 | document.body.setAttribute('connected', this.host.isUplinked()) 46 | }) 47 | this.host.on('off', (spec, val) => { // eslint-disable-line no-unused-vars 48 | document.body.setAttribute('connected', this.host.isUplinked()) 49 | }) 50 | 51 | let unloaded = false 52 | let unload = () => { 53 | if(unloaded) { 54 | return 55 | } 56 | unloaded = true 57 | // bug, Swarm.js does not close the Websocket because Pipe.close() expects stream.close() to exist, get a ref and do it manually 58 | let ws = this.pipe && this.pipe.stream && this.pipe.stream.ws ? this.pipe.stream.ws : null 59 | for(let i = 0; i < this.unloadHooks.length; i++) { 60 | try { 61 | this.unloadHooks[i]() 62 | } catch (e) { 63 | console.warn('Swarm unload hook failed.', e) 64 | } 65 | } 66 | this.host.close(() => { 67 | // bug, Swarm.js does not close the Websocket because Pipe.close() expects stream.close() to exist 68 | this.pipe.close() 69 | if(ws) { 70 | ws.close() 71 | } 72 | }) 73 | } 74 | 75 | // hopefully one of these events works, doesn't seem to be consistent 76 | window.addEventListener('beforeunload', function() { 77 | unload() 78 | }) 79 | window.addEventListener('pagehide', () => { 80 | unload() 81 | }) 82 | window.addEventListener('unload', () => { 83 | unload() 84 | }) 85 | } 86 | 87 | addReonHook(f) { 88 | this.reonHooks.push(f) 89 | } 90 | 91 | addUnloadHook(f) { 92 | this.unloadHooks.push(f) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/core/swarmfactory.js: -------------------------------------------------------------------------------- 1 | import Text from './RichText' 2 | import CursorModel from './CursorModel' 3 | import CursorSet from './CursorSet' 4 | 5 | let SwarmFactory = function SwarmFactory(Swarm) { 6 | Swarm.debug = false 7 | 8 | Swarm.Text = Text 9 | Swarm.CursorModel = CursorModel 10 | Swarm.CursorSet = CursorSet 11 | 12 | let env = Swarm.env 13 | env.debug = false 14 | env.log = (spec, value, source, host) => { // no-unused-vars 15 | //console.log('spec=', spec, 'value=', value, 'source=', source, 'host=', host) 16 | } 17 | 18 | return Swarm 19 | } 20 | 21 | export default SwarmFactory 22 | -------------------------------------------------------------------------------- /src/core/swarmserver.js: -------------------------------------------------------------------------------- 1 | import SwarmBase from 'swarm/lib/NodeServer' 2 | import swarmFactory from './swarmfactory' 3 | import Spec from 'swarm/lib/Spec' 4 | import redis from 'redis' 5 | import RedisStorage from '../vendor/swarm/RedisStorage' 6 | 7 | export default class SwarmServer { 8 | constructor(redisConfig) { 9 | let Swarm = swarmFactory(SwarmBase) 10 | this.Swarm = Swarm 11 | 12 | //let storage = new Swarm.FileStorage('.swarm') 13 | let storage = new RedisStorage({ 14 | redis: redis, 15 | redisConnectParams: redisConfig, 16 | debug: false 17 | }) 18 | storage.open() 19 | 20 | Swarm.host = new Swarm.Host('swarm~nodejs', 0, storage) 21 | Swarm.env.localhost = Swarm.host 22 | 23 | setInterval(() => { 24 | this.cleanCursorSets() 25 | }, 5000) 26 | } 27 | 28 | cleanCursorSet(cursorSet) { 29 | let online = {} 30 | let Swarm = this.Swarm 31 | for (let src in Swarm.host.sources) { 32 | if(!Swarm.host.sources.hasOwnProperty(src)) continue 33 | let m = src.match(/([A-Za-z0-9_\~]+)(\~[A-Za-z0-9_\~]+)/) 34 | if (!m) { 35 | console.error('Unknown Swarm source', src) 36 | continue 37 | } 38 | online[m[1]] = true 39 | } 40 | 41 | if (!cursorSet._version) { 42 | return 43 | } 44 | 45 | let cursorSetMoribund = {} 46 | let cursors = cursorSet.list() 47 | //console.log('cursors', cursors.reduce((arr, c) => { arr.push({_id: c._id, name: c.name, state: c.state, ms: c.ms}); return arr }, [])) 48 | for (let s of cursors) { 49 | let spec = s.spec() 50 | if (spec.type() !== 'Cursor') { 51 | continue 52 | } 53 | let id = spec.id() 54 | cursorSetMoribund[id] = s.ms 55 | } 56 | //console.log('cursorSet:', cursorSet._id, 'cursors:', cursorSetMoribund) 57 | // cursors live for 10 minutes after last use and then disappear (recreated by client if user resumes editing) 58 | let ancient = Date.now() - 10 * 60 * 1000 59 | for (let id in cursorSetMoribund) { 60 | if(!cursorSetMoribund.hasOwnProperty(id)) continue 61 | let ts = cursorSetMoribund[id] 62 | if (ts < ancient) { 63 | cursorSet.removeObject('/Cursor#' + id) 64 | delete cursorSetMoribund[id] 65 | } 66 | } 67 | } 68 | 69 | cleanCursorSets() { 70 | let Swarm = this.Swarm 71 | Object.keys(Swarm.host.objects).map(s => new Spec(s)).filter(s => s.type() === 'CursorSet').forEach(s => { 72 | this.cleanCursorSet(Swarm.host.get(s)) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/core/textwriter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Writes chunks of rich text into a plain text. The implementation is simple: just strip any style 3 | * information from the rich text. 4 | * 5 | * @param {Array} chunks The rich text chunks to write. 6 | */ 7 | export default function writeText(chunks) { 8 | let text = '' 9 | 10 | if(!chunks) { 11 | chunks = [] 12 | } 13 | 14 | chunks.forEach(c => { 15 | text += c.text 16 | }) 17 | 18 | return text 19 | } 20 | -------------------------------------------------------------------------------- /src/core/tokenizer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on https://github.com/timdown/rangy/blob/master/src/modules/rangy-textrange.js#L119 3 | */ 4 | 5 | import _ from 'lodash' 6 | 7 | const DEFAULT_WORD_OPTIONS = { 8 | en: { 9 | wordRegex: /([a-z0-9_-]+('[a-z0-9_-]+)*|\n)/gi, 10 | includeLeadingSpace: false, 11 | includeTrailingSpace: true 12 | } 13 | } 14 | 15 | // does not include line breaks 16 | const WHITESPACE_REGEX = /^[\t \u00A0\u1680\u180E\u2000-\u200B\u202F\u205F\u3000]+$/ 17 | 18 | export function isWhitespace(char) { 19 | return WHITESPACE_REGEX.test(char) 20 | } 21 | 22 | export default function tokenizer(chars, wordOptions) { 23 | let word = _.isArray(chars) ? chars.join('') : chars 24 | let result 25 | let tokenRanges = [] 26 | // by default if our options include leading spaces but trailing is not specified, turn off trailing 27 | if(wordOptions && wordOptions.includeLeadingSpace && !wordOptions.includeTrailingSpace) { 28 | wordOptions.includeTrailingSpace = false 29 | } 30 | wordOptions = _.merge({}, DEFAULT_WORD_OPTIONS.en, wordOptions) 31 | 32 | let createTokenRange = function(start, end, isWord) { 33 | tokenRanges.push({start: start, end: end, isWord: isWord}) 34 | } 35 | 36 | // Match words and mark characters 37 | let lastWordEnd = 0 38 | let wordStart 39 | let wordEnd 40 | while ((result = wordOptions.wordRegex.exec(word))) { 41 | wordStart = result.index 42 | wordEnd = wordStart + result[0].length 43 | 44 | // Get leading space characters for word 45 | if (wordOptions.includeLeadingSpace) { 46 | while (isWhitespace(chars[wordStart - 1])) { 47 | --wordStart 48 | } 49 | } 50 | 51 | // Create token for non-word characters preceding this word 52 | if (wordStart > lastWordEnd) { 53 | createTokenRange(lastWordEnd, wordStart, false) 54 | } 55 | 56 | // Get trailing space characters for word 57 | if (wordOptions.includeTrailingSpace) { 58 | while (isWhitespace(chars[wordEnd])) { 59 | ++wordEnd 60 | } 61 | } 62 | createTokenRange(wordStart, wordEnd, true) 63 | lastWordEnd = wordEnd 64 | } 65 | 66 | // Create token for trailing non-word characters, if any exist 67 | if (lastWordEnd < chars.length) { 68 | createTokenRange(lastWordEnd, chars.length, false) 69 | } 70 | 71 | return tokenRanges 72 | } 73 | -------------------------------------------------------------------------------- /src/core/utils.js: -------------------------------------------------------------------------------- 1 | import invariant from 'react/lib/invariant' 2 | 3 | // http://stackoverflow.com/a/4156156/430128 4 | export function pushArray(arr, arr2) { 5 | arr.push.apply(arr, arr2) 6 | } 7 | 8 | export function pushSet(set1, set2) { 9 | invariant(set2, 'Set to push into must be defined.') 10 | if(!set1) return 11 | for(let value of set1) { 12 | set2.add(value) 13 | } 14 | } 15 | 16 | export function setIntersection(set1, set2) { 17 | if(!set1 || !set2) return [] 18 | return [...set1].filter(x => set2.has(x)) 19 | } 20 | 21 | export function logInGroup(group, f) { 22 | if(console.group) console.group(group) 23 | try { 24 | f() 25 | } finally { 26 | if(console.groupEnd) console.groupEnd() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/flux/EditorActions.js: -------------------------------------------------------------------------------- 1 | import alt from '../core/alt' 2 | 3 | class EditorActions { 4 | constructor() { 5 | } 6 | 7 | initialize(config, replica) { 8 | this.dispatch({config, replica}) 9 | } 10 | 11 | onCursorModelUpdate(cursorModelUpdate) { 12 | this.dispatch(cursorModelUpdate) 13 | } 14 | 15 | replicaInitialized() { 16 | this.dispatch() 17 | } 18 | 19 | replicaUpdated() { 20 | this.dispatch() 21 | } 22 | 23 | focusInput() { 24 | this.dispatch() 25 | } 26 | 27 | inputFocusLost() { 28 | this.dispatch() 29 | } 30 | 31 | reflow() { 32 | this.dispatch() 33 | } 34 | 35 | setRemoteCursorPosition(remoteCursor) { 36 | this.dispatch(remoteCursor) 37 | } 38 | 39 | unsetRemoteCursorPosition(remoteCursor) { 40 | this.dispatch(remoteCursor) 41 | } 42 | 43 | revealRemoteCursorName(remoteCursor) { 44 | this.dispatch(remoteCursor) 45 | } 46 | 47 | // navigation actions 48 | navigateLeft() { 49 | this.dispatch() 50 | } 51 | 52 | navigateRight() { 53 | this.dispatch() 54 | } 55 | 56 | navigateUp() { 57 | this.dispatch() 58 | } 59 | 60 | navigateDown() { 61 | this.dispatch() 62 | } 63 | 64 | navigatePageUp() { 65 | this.dispatch() 66 | } 67 | 68 | navigatePageDown() { 69 | this.dispatch() 70 | } 71 | 72 | navigateStart() { 73 | this.dispatch() 74 | } 75 | 76 | navigateStartLine() { 77 | this.dispatch() 78 | } 79 | 80 | navigateEnd() { 81 | this.dispatch() 82 | } 83 | 84 | navigateEndLine() { 85 | this.dispatch() 86 | } 87 | 88 | navigateWordLeft() { 89 | this.dispatch() 90 | } 91 | 92 | navigateWordRight() { 93 | this.dispatch() 94 | } 95 | 96 | navigateToCoordinates(coordinates) { 97 | this.dispatch(coordinates) 98 | } 99 | 100 | // selection actions 101 | selectionLeft() { 102 | this.dispatch() 103 | } 104 | 105 | selectionRight() { 106 | this.dispatch() 107 | } 108 | 109 | selectionUp() { 110 | this.dispatch() 111 | } 112 | 113 | selectionDown() { 114 | this.dispatch() 115 | } 116 | 117 | selectionPageUp() { 118 | this.dispatch() 119 | } 120 | 121 | selectionPageDown() { 122 | this.dispatch() 123 | } 124 | 125 | selectionStart() { 126 | this.dispatch() 127 | } 128 | 129 | selectionStartLine() { 130 | this.dispatch() 131 | } 132 | 133 | selectionEnd() { 134 | this.dispatch() 135 | } 136 | 137 | selectionEndLine() { 138 | this.dispatch() 139 | } 140 | 141 | selectionWordLeft() { 142 | this.dispatch() 143 | } 144 | 145 | selectionWordRight() { 146 | this.dispatch() 147 | } 148 | 149 | selectionAll() { 150 | this.dispatch() 151 | } 152 | 153 | selectToCoordinates(coordinates) { 154 | this.dispatch(coordinates) 155 | } 156 | 157 | selectWordAtCurrentPosition() { 158 | this.dispatch() 159 | } 160 | 161 | copySelection(copyHandler) { 162 | this.dispatch(copyHandler) 163 | } 164 | 165 | insertChars(value, attributes, atPosition) { 166 | this.dispatch({value, attributes, atPosition}) 167 | } 168 | 169 | insertCharsBatch(chunks) { 170 | this.dispatch(chunks) 171 | } 172 | 173 | eraseCharBack() { 174 | this.dispatch() 175 | } 176 | 177 | eraseCharForward() { 178 | this.dispatch() 179 | } 180 | 181 | eraseWordBack() { 182 | this.dispatch() 183 | } 184 | 185 | eraseWordForward() { 186 | this.dispatch() 187 | } 188 | 189 | eraseSelection() { 190 | this.dispatch() 191 | } 192 | 193 | // toggle attribute actions 194 | toggleBold() { 195 | this.dispatch() 196 | } 197 | 198 | toggleItalics() { 199 | this.dispatch() 200 | } 201 | 202 | toggleUnderline() { 203 | this.dispatch() 204 | } 205 | 206 | toggleStrikethrough() { 207 | this.dispatch() 208 | } 209 | 210 | toggleSuperscript() { 211 | this.dispatch() 212 | } 213 | 214 | toggleSubscript() { 215 | this.dispatch() 216 | } 217 | 218 | setActiveAttributes() { 219 | this.dispatch() 220 | } 221 | 222 | registerEditorError(error) { 223 | this.dispatch(error) 224 | } 225 | 226 | dismissEditorError() { 227 | this.dispatch() 228 | } 229 | } 230 | 231 | export default alt.createActions(EditorActions) 232 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import 'babel/polyfill' 2 | 3 | import _ from 'lodash' 4 | import fs from 'fs' 5 | import path from 'path' 6 | import express from 'express' 7 | import http from 'http' 8 | import url from 'url' 9 | import WebSocket from 'ws' 10 | //import compression from 'compression' 11 | 12 | import SwarmServer from './core/swarmserver' 13 | 14 | let redisConfig = { 15 | port: 6379, 16 | host: '127.0.0.1', 17 | options: {} 18 | } 19 | 20 | let swarmServer = new SwarmServer(redisConfig) 21 | let Swarm = swarmServer.Swarm 22 | 23 | let server = express() 24 | let port = process.env.PORT || 5000 25 | server.set('port', port) 26 | //server.use(compression()) 27 | server.use(express.static(path.join(__dirname))) 28 | 29 | // html page template containing placeholders for title and body 30 | const pageTemplateFile = path.join(__dirname, 'templates/index.html') 31 | const pageTemplate = _.template(fs.readFileSync(pageTemplateFile, 'utf8')) 32 | 33 | server.get('/', (req, res) => { 34 | let data = { 35 | description: '', 36 | title: 'Ritzy Editor' 37 | } 38 | 39 | data.content = '' 40 | 41 | let html = pageTemplate(data) 42 | res.send(html) 43 | }) 44 | 45 | // example of calling this: 46 | // http://localhost:5000/sapi/Text%2310 to return a Text replica 10 47 | // http://localhost:5000/sapi/CursorSet%2310 to return a set of Cursors for editor 10 48 | // http://localhost:5000/sapi/Cursor%2310_A0017r to return the state of Cursor for user id A0017r in editor 10 49 | let apiHandler = require('swarm-restapi').createHandler({ 50 | route: '/sapi', 51 | host: Swarm.host, 52 | authenticate: function(req, cb) {cb(null, null)} // no auth, to implement see sample auth function in swarm-restapi/index.js 53 | }) 54 | server.get(/^\/sapi\//, apiHandler) 55 | server.post(/^\/sapi\//, apiHandler) 56 | server.put(/^\/sapi\//, apiHandler) 57 | 58 | let httpServer = http.createServer(server) 59 | 60 | httpServer.listen(server.get('port'), function(err) { 61 | if (err) { 62 | console.warn('Can\'t start HTTP server. Error: ', err, err.stack) 63 | return 64 | } 65 | 66 | // integration with parent process e.g. gulp 67 | // process.send is available if we are a child process (https://nodejs.org/api/child_process.html) 68 | if (process.send) { 69 | process.send('online') 70 | } 71 | console.log('The HTTP server is listening on port ' + server.get('port')) 72 | }) 73 | 74 | // start WebSocket server 75 | let wsServer = new WebSocket.Server({ 76 | server: httpServer 77 | }) 78 | 79 | // accept pipes on connection 80 | wsServer.on('connection', function(ws) { 81 | let params = url.parse(ws.upgradeReq.url, true) 82 | console.log('Incoming websocket %s', params.path, ws.upgradeReq.connection.remoteAddress) 83 | if (!Swarm.host) { 84 | return ws.close() 85 | } 86 | Swarm.host.accept(new Swarm.EinarosWSStream(ws), {delay: 50}) 87 | }) 88 | 89 | /* eslint-disable no-process-exit */ 90 | function onExit(exitCode) { 91 | console.log('Shutting down http-server...') 92 | httpServer.close(function(err) { 93 | if(err) console.warn('HTTP server close failed: %s', err) 94 | else console.log('HTTP server closed.') 95 | }) 96 | 97 | if (!Swarm.host) { 98 | console.log('Swarm host not created yet...') 99 | return process.exit(exitCode) 100 | } 101 | 102 | console.log('Closing swarm host...') 103 | let forcedExit = setTimeout(function() { 104 | console.log('Swarm host close timeout, forcing exit.') 105 | process.exit(exitCode) 106 | }, 5000) 107 | 108 | Swarm.host.close(function() { 109 | console.log('Swarm host closed.') 110 | clearTimeout(forcedExit) 111 | process.exit(exitCode) 112 | }) 113 | } 114 | /* eslint-enable no-process-exit */ 115 | 116 | process.on('SIGTERM', onExit) 117 | process.on('SIGINT', onExit) 118 | process.on('SIGQUIT', onExit) 119 | 120 | process.on('uncaughtException', function(err) { 121 | console.error('Uncaught Exception: ', err, err.stack) 122 | onExit(2) 123 | }) 124 | -------------------------------------------------------------------------------- /src/styles/default-skin.less: -------------------------------------------------------------------------------- 1 | // the editor bounding box (including the left-right and top-bottom text margins) 2 | .text-content-wrapper { 3 | border: dotted black 1px; 4 | } 5 | 6 | // the text bounding box inside the margins 7 | .text-contents { 8 | } 9 | 10 | // selection overlay 11 | .text-selection-overlay { 12 | background-color: #76a7fa; 13 | border-top: 1px solid #76a7fa; 14 | border-bottom: 1px solid #76a7fa; 15 | opacity: 0.50; 16 | } 17 | 18 | // the caret portion of the cursor 19 | .text-cursor-caret { 20 | } 21 | 22 | // the square portion of the cursor above the caret when showing a cursor name 23 | .text-cursor-top { 24 | } 25 | 26 | // the name associated with a cursor 27 | .text-cursor-name { 28 | } 29 | 30 | .ritzy-error-notification { 31 | border: 1px solid; 32 | margin: 10px 0; 33 | padding: 5px; 34 | color: #d8000c; 35 | background: #ffbaba no-repeat 10px center; 36 | } 37 | 38 | .ritzy-error-notification-dismiss { 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/internal.less: -------------------------------------------------------------------------------- 1 | @media print { 2 | .ritzy-internal-ui-unprintable { 3 | display: none!important; 4 | } 5 | } 6 | 7 | .ritzy-internal-text-content-wrapper { 8 | cursor: text; 9 | overflow: hidden; 10 | position: relative; 11 | white-space: normal; 12 | tap-highlight-color: initial; 13 | z-index: 22; 14 | user-select: none; 15 | *, *:before, *:after { 16 | box-sizing: border-box; 17 | } 18 | } 19 | 20 | .ritzy-internal-text-contents { 21 | position: relative; 22 | } 23 | 24 | .ritzy-internal-text-lineview { 25 | position: relative; 26 | } 27 | 28 | .ritzy-internal-text-lineview-content { 29 | white-space: nowrap; 30 | position: absolute; 31 | z-index: 15; 32 | } 33 | 34 | .ritzy-internal-text-lineview-text-block { 35 | white-space: nowrap; 36 | } 37 | 38 | .ritzy-internal-text-cursor { 39 | cursor: text; 40 | position: absolute; 41 | z-index: 24; 42 | } 43 | 44 | .ritzy-internal-text-cursor-caret { 45 | position: absolute; 46 | width: 0; 47 | border-left: 2px solid; 48 | font-size: 0; 49 | } 50 | 51 | .ritzy-internal-text-cursor-top { 52 | position: absolute; 53 | width: 6px; 54 | left: -2px; 55 | top: -2px; 56 | height: 6px; 57 | font-size: 0; 58 | } 59 | 60 | .ritzy-internal-text-cursor-name { 61 | position: absolute; 62 | font-size: 10px; 63 | color: #fff; 64 | top: -14px; 65 | left: -2px; 66 | padding: 2px; 67 | white-space: nowrap; 68 | } 69 | 70 | .ritzy-internal-text-cursor-italic { 71 | display: inline; 72 | transform: rotate(13deg); 73 | } 74 | 75 | .ritzy-internal-text-cursor-blink { 76 | animation-duration: 1s; 77 | animation-iteration-count: infinite; 78 | animation-name: ritzy-internal-text-cursor-fadeoutin; 79 | } 80 | 81 | @keyframes ritzy-internal-text-cursor-fadeoutin { 82 | from { 83 | opacity: 1; 84 | } 85 | 13% { 86 | opacity: 0; 87 | } 88 | 50% { 89 | opacity: 0; 90 | } 91 | 63% { 92 | opacity: 1; 93 | } 94 | to { 95 | opacity: 1; 96 | } 97 | } 98 | 99 | .ritzy-internal-text-selection-overlay { 100 | z-index: 20; 101 | } 102 | 103 | .ritzy-internal-text-selection-overlay.ritzy-internal-text-htmloverlay-under-text { 104 | z-index: 12; 105 | } 106 | 107 | .ritzy-internal-text-htmloverlay { 108 | position: absolute; 109 | z-index: 17; 110 | top: 0; 111 | } 112 | 113 | .ritzy-internal-editor-inline-block { 114 | position: relative; 115 | display: inline-block; 116 | } 117 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |
13 | * var storage = new Swarm.RedisStorage('dummy', {
14 | * redis: require('redis'),
15 | * redisConnectParams: {
16 | * port: 6379,
17 | * host: '127.0.0.1',
18 | * options: {}
19 | * }
20 | * });
21 | * storage.open(callback);
22 | *
23 | *
24 | * @TODO storage opening by host
25 | */
26 | function RedisStorage (options) {
27 | Storage.call(this);
28 | this.options = options;
29 | this._host = null; // will be set by the Host
30 | this.redis = options.redis;
31 | this.redisConnectParams = options.redisConnectParams || {
32 | unixSocket: undefined,
33 | port: 6379,
34 | host: '127.0.0.1',
35 | options: {}
36 | };
37 | this.db = null;
38 | this.logtails = {};
39 | }
40 | RedisStorage.prototype = new Storage();
41 | module.exports = RedisStorage;
42 | RedisStorage.prototype.isRoot = env.isServer;
43 |
44 | var TAIL_FIELD_SUFFIX = ":log";
45 |
46 | RedisStorage.prototype.open = function (callback) {
47 | var params = this.redisConnectParams;
48 | if (params.unixSocket) {
49 | this.db = this.redis.createClient(params.unixSocket, params.options || {});
50 | } else {
51 | this.db = this.redis.createClient(params.port || 6379, params.host || '127.0.0.1', params.options || {});
52 | }
53 | if(callback) this.db.on('ready', callback);
54 | };
55 |
56 | RedisStorage.prototype.writeState = function (spec, state, cb) {
57 | if(this.options.debug) console.log('>STATE',state);
58 | var self = this;
59 | var ti = spec.filter('/#');
60 | //var save = JSON.stringify(state, undefined, 2);
61 | if (!self.db) {
62 | console.warn('the storage is not open', this._host && this._host._id);
63 | return;
64 | }
65 |
66 | var json = JSON.stringify(state);
67 | var cleanup = this.logtails[ti] || [];
68 | delete this.logtails[ti];
69 |
70 | if(this.options.debug) console.log('>FLUSH',json,cleanup.length);
71 | self.db.set(ti, json, function onSave(err) {
72 | if (!err && cleanup.length && self.db) {
73 | if(self.options.debug) console.log('>CLEAN',cleanup);
74 | cleanup.unshift(ti + TAIL_FIELD_SUFFIX);
75 | self.db.hdel(cleanup, function (err, entriesRemoved) {
76 | err && console.error('log trimming failed',err);
77 | });
78 | }
79 | err && console.error("state write error", err);
80 | cb(err);
81 | });
82 |
83 | };
84 |
85 | RedisStorage.prototype.writeOp = function (spec, value, cb) {
86 | var ti = spec.filter('/#');
87 | var vo = spec.filter('!.');
88 | spec = spec.toString();
89 | var json = JSON.stringify(value);
90 | if(this.options.debug) console.log('>OP', spec, json);
91 |
92 | // store spec in logtail
93 | var log = this.logtails[ti] || (this.logtails[ti] = []);
94 | log.push(vo);
95 | // save op in redis
96 | var logFieldName = ti + TAIL_FIELD_SUFFIX;
97 | this.db.hset(logFieldName, vo, json, function (err) {
98 | err && console.error('op write error',err);
99 | cb(err);
100 | });
101 | };
102 |
103 | RedisStorage.prototype.readState = function (ti, callback) {
104 | var self = this;
105 | this.db.get(ti.toString(), function (err, value){
106 | if (err) {
107 | return callback(err);
108 | }
109 |
110 | if (!value) {
111 | value = {_version: '!0'};
112 | } else {
113 | value = JSON.parse(value);
114 | }
115 |
116 | if(self.options.debug) console.log('