Implement functions `getPlainText` and `setPlainText`

This commit is contained in:
josdejong 2020-05-13 20:21:44 +02:00
parent fce4c7910a
commit 5cc3665c47
6 changed files with 211 additions and 180 deletions

View File

@ -1,14 +1,12 @@
<script> <script>
import { getPlainText, setPlainText } from './utils/domUtils.js'
import Icon from 'svelte-awesome' import Icon from 'svelte-awesome'
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons' import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'
import { SEARCH_PROPERTY, SEARCH_VALUE } from './search' import { SEARCH_PROPERTY, SEARCH_VALUE } from './search'
import classnames from 'classnames' import classnames from 'classnames'
import debounce from 'lodash/debounce' import debounce from 'lodash/debounce'
import { isUrl, stringConvert, valueType } from './utils/typeUtils' import { isUrl, stringConvert, valueType } from './utils/typeUtils'
import { escapeHTML } from './utils/stringUtils.js'
import { updateProps } from './utils/updateProps.js' import { updateProps } from './utils/updateProps.js'
import { unescapeHTML } from './utils/stringUtils'
import { getInnerText } from './utils/domUtils'
import { compileJSONPointer } from './utils/jsonPointer' import { compileJSONPointer } from './utils/jsonPointer'
export let key = undefined // only applicable for object properties export let key = undefined // only applicable for object properties
@ -52,12 +50,10 @@
? limited ? value.slice(0, limit) : value ? limited ? value.slice(0, limit) : value
: undefined : undefined
$: escapedKey = escapeHTML(key, escapeUnicode)
$: escapedValue = escapeHTML(value, escapeUnicode)
$: valueIsUrl = isUrl(value) $: valueIsUrl = isUrl(value)
$: keyClass = classnames('key', { $: keyClass = classnames('key', {
empty: escapedKey.length === 0, empty: key === '',
search: searchResult search: searchResult
? !!searchResult[SEARCH_PROPERTY] ? !!searchResult[SEARCH_PROPERTY]
: false : false
@ -67,20 +63,20 @@
$: valueClass = getValueClass(value, searchResult) $: valueClass = getValueClass(value, searchResult)
$: if (domKey) { $: if (domKey) {
if (document.activeElement !== domKey || escapedKey === '') { if (document.activeElement !== domKey || key === '') {
// synchronize the innerText of the editable div with the escaped value, // synchronize the innerText of the editable div with the escaped value,
// but only when the domValue does not have focus else we will ruin // but only when the domValue does not have focus else we will ruin
// the cursor position. // the cursor position.
domKey.innerText = escapedKey setPlainText(domKey, key)
} }
} }
$: if (domValue) { $: if (domValue) {
if (document.activeElement !== domValue || escapedValue === '') { if (document.activeElement !== domValue || value === '') {
// synchronize the innerText of the editable div with the escaped value, // synchronize the innerText of the editable div with the escaped value,
// but only when the domValue does not have focus else we will ruin // but only when the domValue does not have focus else we will ruin
// the cursor position. // the cursor position.
domValue.innerText = escapedValue setPlainText(domValue, value)
} }
} }
@ -89,7 +85,7 @@
return classnames('value', type, { return classnames('value', type, {
url: isUrl(value), url: isUrl(value),
empty: escapedValue.length === 0, empty: typeof value === 'string' && value.length === 0,
search: searchResult search: searchResult
? !!searchResult[SEARCH_VALUE] ? !!searchResult[SEARCH_VALUE]
: false : false
@ -101,7 +97,7 @@
} }
function updateKey () { function updateKey () {
const newKey = unescapeHTML(getInnerText(domKey)) const newKey = getPlainText(domKey)
// TODO: replace the onChangeKey callback with gobally managed JSONNode id's, // TODO: replace the onChangeKey callback with gobally managed JSONNode id's,
// which are kept in sync with the json itself using JSONPatch // which are kept in sync with the json itself using JSONPatch
@ -125,12 +121,12 @@
updateKeyDebounced.flush() updateKeyDebounced.flush()
// make sure differences in escaped text like with new lines is updated // make sure differences in escaped text like with new lines is updated
domKey.innerText = escapedValue setPlainText(domKey, key)
} }
// get the value from the DOM // get the value from the DOM
function getValue () { function getValue () {
const valueText = unescapeHTML(getInnerText(domValue)) const valueText = getPlainText(domValue)
return stringConvert(valueText) // TODO: implement support for type "string" return stringConvert(valueText) // TODO: implement support for type "string"
} }
@ -159,7 +155,7 @@
debouncedUpdateValue.flush() debouncedUpdateValue.flush()
// make sure differences in escaped text like with new lines is updated // make sure differences in escaped text like with new lines is updated
domValue.innerText = escapedValue setPlainText(domValue, value)
} }
function handleValueClick (event) { function handleValueClick (event) {

View File

@ -1,11 +1,10 @@
$font-family: "dejavu sans mono", "droid sans mono", consolas, monaco, "lucida console", "courier new", courier, monospace, sans-serif; $font-family: consolas, monaco, "lucida console", "courier new", "dejavu sans mono", "droid sans mono", courier, monospace, sans-serif;
$font-size: 10pt; $font-size: 10pt;
$font-size-small: 8pt; $font-size-small: 8pt;
$font-family-menu: arial, "sans-serif"; $font-family-menu: arial, "sans-serif";
$font-size-icon: 16px; $font-size-icon: 16px;
$white: #fff; $white: #fff;
$black: #1A1A1A; $black: #1A1A1A;
$contentsMinHeight: 150px; $contentsMinHeight: 150px;

View File

@ -1,60 +1,180 @@
// TODO: write unit tests for getPlainText and setPlainText
/**
* Get the plain text from an HTML element
* @param {Element} element An HTML DOM element like a DIV
* @return {string}
*/
export function getPlainText(element) {
return unescapeHTML(traverseInnerText(element))
}
/**
* Set plain text in an HTML element
* @param {Element} element An HTML DOM element like a DIV
* @param {string} text
*/
export function setPlainText(element, text) {
element.innerHTML = escapeHTML(text)
}
/**
* escape a text, such that it can be displayed safely in an HTML element
* @param {string} text
* @param {boolean} [escapeUnicode=false]
* @return {string} escapedText
*/
export function escapeHTML (text, escapeUnicode = false) {
if (typeof text !== 'string') {
return String(text)
}
else {
let htmlEscaped = String(text)
if (escapeUnicode === true) {
// FIXME: should not unescape the just created non-breaking spaces \u00A0 ?
htmlEscaped = escapeUnicodeChars(htmlEscaped)
}
htmlEscaped = htmlEscaped
.replace(/ {2}/g, ' \u00A0') // replace double space with an nbsp and space
.replace(/^ /, '\u00A0') // space at start
.replace(/ $/, '\u00A0') // space at end
const json = JSON.stringify(htmlEscaped)
return json.substring(1, json.length - 1) // remove enclosing double quotes
}
}
/**
* Escape unicode characters.
* For example input '\u2661' (length 1) will output '\\u2661' (length 5).
* @param {string} text
* @return {string}
*/
export function escapeUnicodeChars (text) {
// see https://www.wikiwand.com/en/UTF-16
// note: we leave surrogate pairs as two individual chars,
// as JSON doesn't interpret them as a single unicode char.
return text.replace(/[\u007F-\uFFFF]/g, function(c) {
return '\\u'+('0000' + c.charCodeAt(0).toString(16)).slice(-4)
})
}
/**
* unescape a string.
* @param {string} escapedText
* @return {string} text
*/
export function unescapeHTML (escapedText) {
const json = '"' + escapeJSON(escapedText) + '"'
const htmlEscaped = JSON.parse(json) // TODO: replace with a JSON.parse which does do linting and give an informative error
return htmlEscaped.replace(/\u00A0/g, ' ') // nbsp character
}
/**
* escape a text to make it a valid JSON string. The method will:
* - replace unescaped double quotes with '\"'
* - replace unescaped backslash with '\\'
* - replace returns with '\n'
* @param {string} text
* @return {string} escapedText
* @private
*/
export function escapeJSON (text) {
// TODO: replace with some smart regex (only when a new solution is faster!)
let escaped = ''
let i = 0
while (i < text.length) {
let c = text.charAt(i)
if (c === '\n') {
escaped += '\\n'
}
else if (c === '\\') {
escaped += c
i++
c = text.charAt(i)
if (c === '' || '"\\/bfnrtu'.indexOf(c) === -1) {
escaped += '\\' // no valid escape character
}
escaped += c
}
else if (c === '"') {
escaped += '\\"'
}
else {
escaped += c
}
i++
}
return escaped
}
/** /**
* Get the inner text of an HTML element (for example a div element) * Get the inner text of an HTML element (for example a div element)
* @param {Element} element * @param {Element} element
* @param {Object} [buffer] * @param {Object} [buffer]
* @return {String} innerText * @return {string} innerText
*/ */
export function getInnerText (element, buffer) { export function traverseInnerText (element, buffer) {
if (buffer === undefined) { const first = (buffer === undefined)
buffer = { if (first) {
'text': '', buffer = {
'flush': function () { _text: '',
const text = this.text flush: function () {
this.text = '' const text = this._text
return text this._text = ''
}, return text
'set': function (text) { },
this.text = text set: function (text) {
} this._text = text
} }
} }
// text node
if (element.nodeValue) {
return buffer.flush() + element.nodeValue
}
// divs or other HTML elements
if (element.hasChildNodes()) {
const childNodes = element.childNodes
let innerText = ''
for (let i = 0, iMax = childNodes.length; i < iMax; i++) {
const child = childNodes[i]
if (child.nodeName === 'DIV' || child.nodeName === 'P') {
const prevChild = childNodes[i - 1]
const prevName = prevChild ? prevChild.nodeName : undefined
if (prevName && prevName !== 'DIV' && prevName !== 'P' && prevName !== 'BR') {
innerText += '\n'
buffer.flush()
}
innerText += getInnerText(child, buffer)
buffer.set('\n')
}
else if (child.nodeName === 'BR') {
innerText += buffer.flush()
buffer.set('\n')
}
else {
innerText += getInnerText(child, buffer)
}
}
return innerText
}
// br or unknown
return ''
} }
// text node
if (element.nodeValue) {
// remove return characters and the whitespace surrounding return characters
const trimmedValue = element.nodeValue.replace(/\s*\n\s*/g, '')
if (trimmedValue !== '') {
return buffer.flush() + trimmedValue
} else {
// ignore empty text
return ''
}
}
// divs or other HTML elements
if (element.hasChildNodes()) {
const childNodes = element.childNodes
let innerText = ''
for (let i = 0, iMax = childNodes.length; i < iMax; i++) {
const child = childNodes[i]
if (child.nodeName === 'DIV' || child.nodeName === 'P') {
const prevChild = childNodes[i - 1]
const prevName = prevChild ? prevChild.nodeName : undefined
if (prevName && prevName !== 'DIV' && prevName !== 'P' && prevName !== 'BR') {
if (innerText !== '') {
innerText += '\n'
}
buffer.flush()
}
innerText += traverseInnerText(child, buffer)
buffer.set('\n')
} else if (child.nodeName === 'BR') {
innerText += buffer.flush()
buffer.set('\n')
} else {
innerText += traverseInnerText(child, buffer)
}
}
return innerText
}
// br or unknown
return ''
}

View File

@ -0,0 +1,21 @@
import { escapeHTML, unescapeHTML } from './domUtils.js'
import { expect } from './testUtils.js' // FIXME: replace jest with mocha tests, or move to jest
const test = it // TODO: replace jest with mocha tests, or move to jest
test('escapeHTML', () => {
expect(escapeHTML(' hello ')).toEqual('\u00A0\u00A0 hello \u00A0')
expect(escapeHTML('\u00A0 hello')).toEqual('\u00A0 hello')
expect(escapeHTML('hello\nworld')).toEqual('hello\\nworld')
// TODO: test escapeHTML more thoroughly
})
test('unescapeHTML', () => {
expect(unescapeHTML(' \u00A0 hello \u00A0')).toEqual(' hello ')
expect(unescapeHTML('\u00A0 hello')).toEqual(' hello')
expect(unescapeHTML('hello\\nworld')).toEqual('hello\nworld')
// TODO: test unescapeHTML more thoroughly
})

View File

@ -1,96 +1,3 @@
/**
* escape a text, such that it can be displayed safely in an HTML element
* @param {String} text
* @param {boolean} [escapeUnicode=false]
* @return {String} escapedText
*/
export function escapeHTML (text, escapeUnicode = false) {
if (typeof text !== 'string') {
return String(text)
}
else {
let htmlEscaped = String(text)
if (escapeUnicode === true) {
// FIXME: should not unescape the just created non-breaking spaces \u00A0 ?
htmlEscaped = escapeUnicodeChars(htmlEscaped)
}
htmlEscaped = htmlEscaped
.replace(/ {2}/g, ' \u00A0') // replace double space with an nbsp and space
.replace(/^ /, '\u00A0') // space at start
.replace(/ $/, '\u00A0') // space at end
const json = JSON.stringify(htmlEscaped)
return json.substring(1, json.length - 1) // remove enclosing double quotes
}
}
/**
* Escape unicode characters.
* For example input '\u2661' (length 1) will output '\\u2661' (length 5).
* @param {string} text
* @return {string}
*/
export function escapeUnicodeChars (text) {
// see https://www.wikiwand.com/en/UTF-16
// note: we leave surrogate pairs as two individual chars,
// as JSON doesn't interpret them as a single unicode char.
return text.replace(/[\u007F-\uFFFF]/g, function(c) {
return '\\u'+('0000' + c.charCodeAt(0).toString(16)).slice(-4)
})
}
/**
* unescape a string.
* @param {String} escapedText
* @return {String} text
*/
export function unescapeHTML (escapedText) {
const json = '"' + escapeJSON(escapedText) + '"'
const htmlEscaped = JSON.parse(json) // TODO: replace with a JSON.parse which does do linting and give an informative error
return htmlEscaped.replace(/\u00A0/g, ' ') // nbsp character
}
/**
* escape a text to make it a valid JSON string. The method will:
* - replace unescaped double quotes with '\"'
* - replace unescaped backslash with '\\'
* - replace returns with '\n'
* @param {String} text
* @return {String} escapedText
* @private
*/
export function escapeJSON (text) {
// TODO: replace with some smart regex (only when a new solution is faster!)
let escaped = ''
let i = 0
while (i < text.length) {
let c = text.charAt(i)
if (c === '\n') {
escaped += '\\n'
}
else if (c === '\\') {
escaped += c
i++
c = text.charAt(i)
if (c === '' || '"\\/bfnrtu'.indexOf(c) === -1) {
escaped += '\\' // no valid escape character
}
escaped += c
}
else if (c === '"') {
escaped += '\\"'
}
else {
escaped += c
}
i++
}
return escaped
}
/** /**
* Find a unique name. Suffix the name with ' (copy)', '(copy 2)', etc * Find a unique name. Suffix the name with ' (copy)', '(copy 2)', etc

View File

@ -1,25 +1,13 @@
import { compareStrings, duplicateInText, escapeHTML, findUniqueName, toCapital, unescapeHTML } from './stringUtils.js' import {
compareStrings,
duplicateInText,
findUniqueName,
toCapital
} from './stringUtils.js'
import { expect } from './testUtils.js' // FIXME: replace jest with mocha tests, or move to jest import { expect } from './testUtils.js' // FIXME: replace jest with mocha tests, or move to jest
const test = it // TODO: replace jest with mocha tests, or move to jest const test = it // TODO: replace jest with mocha tests, or move to jest
test('escapeHTML', () => {
expect(escapeHTML(' hello ')).toEqual('\u00A0\u00A0 hello \u00A0')
expect(escapeHTML('\u00A0 hello')).toEqual('\u00A0 hello')
expect(escapeHTML('hello\nworld')).toEqual('hello\\nworld')
// TODO: test escapeHTML more thoroughly
})
test('unescapeHTML', () => {
expect(unescapeHTML(' \u00A0 hello \u00A0')).toEqual(' hello ')
expect(unescapeHTML('\u00A0 hello')).toEqual(' hello')
expect(unescapeHTML('hello\\nworld')).toEqual('hello\nworld')
// TODO: test unescapeHTML more thoroughly
})
test('findUniqueName', () => { test('findUniqueName', () => {
expect(findUniqueName('other', {'a': true, 'b': true, 'c': true})).toEqual('other') expect(findUniqueName('other', {'a': true, 'b': true, 'c': true})).toEqual('other')
expect(findUniqueName('b', {'a': true, 'b': true, 'c': true})).toEqual('b (copy)') expect(findUniqueName('b', {'a': true, 'b': true, 'c': true})).toEqual('b (copy)')