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

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
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', () => {
expect(findUniqueName('other', {'a': true, 'b': true, 'c': true})).toEqual('other')
expect(findUniqueName('b', {'a': true, 'b': true, 'c': true})).toEqual('b (copy)')