From 597af1ef1fbb0b6afb3d9e1e403505763cfac5e5 Mon Sep 17 00:00:00 2001 From: Vlad Glushchuk Date: Mon, 8 Apr 2019 20:28:16 +0200 Subject: [PATCH] Add bind:text and bind:html support for contenteditable elements Fixes #310 --- src/compile/nodes/Element.ts | 21 ++++++++- .../render-dom/wrappers/Element/Binding.ts | 16 +++++++ .../render-dom/wrappers/Element/index.ts | 6 +++ src/compile/render-ssr/handlers/Element.ts | 23 +++++++--- .../samples/contenteditable-html/_config.js | 43 +++++++++++++++++++ .../samples/contenteditable-html/main.svelte | 6 +++ .../samples/contenteditable-text/_config.js | 37 ++++++++++++++++ .../samples/contenteditable-text/main.svelte | 6 +++ .../contenteditable-dynamic/errors.json | 15 +++++++ .../contenteditable-dynamic/input.svelte | 6 +++ .../contenteditable-missing/errors.json | 15 +++++++ .../contenteditable-missing/input.svelte | 4 ++ 12 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 test/runtime/samples/contenteditable-html/_config.js create mode 100644 test/runtime/samples/contenteditable-html/main.svelte create mode 100644 test/runtime/samples/contenteditable-text/_config.js create mode 100644 test/runtime/samples/contenteditable-text/main.svelte create mode 100644 test/validator/samples/contenteditable-dynamic/errors.json create mode 100644 test/validator/samples/contenteditable-dynamic/input.svelte create mode 100644 test/validator/samples/contenteditable-missing/errors.json create mode 100644 test/validator/samples/contenteditable-missing/input.svelte diff --git a/src/compile/nodes/Element.ts b/src/compile/nodes/Element.ts index b38ee5b6fbde..2072b01af9f6 100644 --- a/src/compile/nodes/Element.ts +++ b/src/compile/nodes/Element.ts @@ -579,7 +579,26 @@ export default class Element extends Node { message: `'${binding.name}' is not a valid binding on void elements like <${this.name}>. Use a wrapper element instead` }); } - } else if (name !== 'this') { + } else if ( + name === 'text' || + name === 'html' + ){ + const contenteditable = this.attributes.find( + (attribute: Attribute) => attribute.name === 'contenteditable' + ); + + if (!contenteditable) { + component.error(binding, { + code: `missing-contenteditable-attribute`, + message: `'contenteditable' attribute is required for text and html two-way bindings` + }); + } else if (contenteditable && !contenteditable.is_static) { + component.error(contenteditable, { + code: `dynamic-contenteditable-attribute`, + message: `'contenteditable' attribute cannot be dynamic if element uses two-way binding` + }); + } + } else if (name !== 'this') { component.error(binding, { code: `invalid-binding`, message: `'${binding.name}' is not a valid binding` diff --git a/src/compile/render-dom/wrappers/Element/Binding.ts b/src/compile/render-dom/wrappers/Element/Binding.ts index 828d664001ec..d0e5375aeff8 100644 --- a/src/compile/render-dom/wrappers/Element/Binding.ts +++ b/src/compile/render-dom/wrappers/Element/Binding.ts @@ -201,6 +201,14 @@ function get_dom_updater( return `${element.var}.checked = ${condition};` } + if (binding.node.name === 'text') { + return `${element.var}.textContent = ${binding.snippet};`; + } + + if (binding.node.name === 'html') { + return `${element.var}.innerHTML = ${binding.snippet};`; + } + return `${element.var}.${binding.node.name} = ${binding.snippet};`; } @@ -313,6 +321,14 @@ function get_value_from_dom( return `@time_ranges_to_array(this.${name})` } + if (name === 'text') { + return `this.textContent`; + } + + if (name === 'html') { + return `this.innerHTML`; + } + // everything else return `this.${name}`; } diff --git a/src/compile/render-dom/wrappers/Element/index.ts b/src/compile/render-dom/wrappers/Element/index.ts index 22ea7a78cd38..a770eb3ac41e 100644 --- a/src/compile/render-dom/wrappers/Element/index.ts +++ b/src/compile/render-dom/wrappers/Element/index.ts @@ -29,6 +29,12 @@ const events = [ node.name === 'textarea' || node.name === 'input' && !/radio|checkbox|range/.test(node.get_static_attribute_value('type')) }, + { + event_names: ['input'], + filter: (node: Element, name: string) => + (name === 'text' || name === 'html') && + node.attributes.some(attribute => attribute.name === 'contenteditable') + }, { event_names: ['change'], filter: (node: Element, name: string) => diff --git a/src/compile/render-ssr/handlers/Element.ts b/src/compile/render-ssr/handlers/Element.ts index 4c48c8513357..3c52cd45e177 100644 --- a/src/compile/render-ssr/handlers/Element.ts +++ b/src/compile/render-ssr/handlers/Element.ts @@ -48,7 +48,12 @@ const boolean_attributes = new Set([ export default function(node, renderer, options) { let opening_tag = `<${node.name}`; - let textarea_contents; // awkward special case + let node_contents; // awkward special case + const contenteditable = ( + node.name !== 'textarea' && + node.name !== 'input' && + node.attributes.some((attribute: Node) => attribute.name === 'contenteditable') + ); const slot = node.get_static_attribute_value('slot'); const component = node.find_nearest(/InlineComponent/); @@ -85,7 +90,7 @@ export default function(node, renderer, options) { args.push(snip(attribute.expression)); } else { if (attribute.name === 'value' && node.name === 'textarea') { - textarea_contents = stringify_attribute(attribute, true); + node_contents = stringify_attribute(attribute, true); } else if (attribute.is_true) { args.push(`{ ${quote_name_if_necessary(attribute.name)}: true }`); } else if ( @@ -107,7 +112,7 @@ export default function(node, renderer, options) { if (attribute.type !== 'Attribute') return; if (attribute.name === 'value' && node.name === 'textarea') { - textarea_contents = stringify_attribute(attribute, true); + node_contents = stringify_attribute(attribute, true); } else if (attribute.is_true) { opening_tag += ` ${attribute.name}`; } else if ( @@ -136,6 +141,14 @@ export default function(node, renderer, options) { if (name === 'group') { // TODO server-render group bindings + } else if (contenteditable && (node === 'text' || node === 'html')) { + const snippet = snip(expression) + if (name == 'text') { + node_contents = '${@escape(' + snippet + ')}' + } else { + // Do not escape HTML content + node_contents = '${' + snippet + '}' + } } else { const snippet = snip(expression); opening_tag += ' ${(v => v ? ("' + name + '" + (v === true ? "" : "=" + JSON.stringify(v))) : "")(' + snippet + ')}'; @@ -150,8 +163,8 @@ export default function(node, renderer, options) { renderer.append(opening_tag); - if (node.name === 'textarea' && textarea_contents !== undefined) { - renderer.append(textarea_contents); + if ((node.name === 'textarea' || contenteditable) && node_contents !== undefined) { + renderer.append(node_contents); } else { renderer.render(node.children, options); } diff --git a/test/runtime/samples/contenteditable-html/_config.js b/test/runtime/samples/contenteditable-html/_config.js new file mode 100644 index 000000000000..cd2a82265547 --- /dev/null +++ b/test/runtime/samples/contenteditable-html/_config.js @@ -0,0 +1,43 @@ +export default { + props: { + name: 'world', + }, + + html: ` + world +

hello world

+ `, + + ssrHtml: ` + world +

hello world

+ `, + + async test({ assert, component, target, window }) { + const el = target.querySelector('editor'); + assert.equal(el.innerHTML, 'world'); + + el.innerHTML = 'everybody'; + + // No updates to data yet + assert.htmlEqual(target.innerHTML, ` + everybody +

hello world

+ `); + + // Handle user input + const event = new window.Event('input'); + await el.dispatchEvent(event); + assert.htmlEqual(target.innerHTML, ` + everybody +

hello everybody

+ `); + + component.name = 'goodbye'; + assert.equal(el.innerHTML, 'goodbye'); + assert.htmlEqual(target.innerHTML, ` + goodbye +

hello goodbye

+ `); + }, +}; diff --git a/test/runtime/samples/contenteditable-html/main.svelte b/test/runtime/samples/contenteditable-html/main.svelte new file mode 100644 index 000000000000..53b4e81c8876 --- /dev/null +++ b/test/runtime/samples/contenteditable-html/main.svelte @@ -0,0 +1,6 @@ + + + +

hello {@html name}

\ No newline at end of file diff --git a/test/runtime/samples/contenteditable-text/_config.js b/test/runtime/samples/contenteditable-text/_config.js new file mode 100644 index 000000000000..4935a3a9a751 --- /dev/null +++ b/test/runtime/samples/contenteditable-text/_config.js @@ -0,0 +1,37 @@ +export default { + props: { + name: 'world', + }, + + html: ` + world +

hello world

+ `, + + ssrHtml: ` + world +

hello world

+ `, + + async test({ assert, component, target, window }) { + const el = target.querySelector('editor'); + assert.equal(el.textContent, 'world'); + + const event = new window.Event('input'); + + el.textContent = 'everybody'; + await el.dispatchEvent(event); + + assert.htmlEqual(target.innerHTML, ` + everybody +

hello everybody

+ `); + + component.name = 'goodbye'; + assert.equal(el.textContent, 'goodbye'); + assert.htmlEqual(target.innerHTML, ` + goodbye +

hello goodbye

+ `); + }, +}; diff --git a/test/runtime/samples/contenteditable-text/main.svelte b/test/runtime/samples/contenteditable-text/main.svelte new file mode 100644 index 000000000000..a71d9f0c5b2e --- /dev/null +++ b/test/runtime/samples/contenteditable-text/main.svelte @@ -0,0 +1,6 @@ + + + +

hello {name}

\ No newline at end of file diff --git a/test/validator/samples/contenteditable-dynamic/errors.json b/test/validator/samples/contenteditable-dynamic/errors.json new file mode 100644 index 000000000000..0c4c5585a65e --- /dev/null +++ b/test/validator/samples/contenteditable-dynamic/errors.json @@ -0,0 +1,15 @@ +[{ + "code": "dynamic-contenteditable-attribute", + "message": "'contenteditable' attribute cannot be dynamic if element uses two-way binding", + "start": { + "line": 6, + "column": 8, + "character": 73 + }, + "end": { + "line": 6, + "column": 32, + "character": 97 + }, + "pos": 73 +}] \ No newline at end of file diff --git a/test/validator/samples/contenteditable-dynamic/input.svelte b/test/validator/samples/contenteditable-dynamic/input.svelte new file mode 100644 index 000000000000..97d2c9228c89 --- /dev/null +++ b/test/validator/samples/contenteditable-dynamic/input.svelte @@ -0,0 +1,6 @@ + + diff --git a/test/validator/samples/contenteditable-missing/errors.json b/test/validator/samples/contenteditable-missing/errors.json new file mode 100644 index 000000000000..9cadb20629a1 --- /dev/null +++ b/test/validator/samples/contenteditable-missing/errors.json @@ -0,0 +1,15 @@ +[{ + "code": "missing-contenteditable-attribute", + "message": "'contenteditable' attribute is required for text and html two-way bindings", + "start": { + "line": 4, + "column": 8, + "character": 48 + }, + "end": { + "line": 4, + "column": 24, + "character": 64 + }, + "pos": 48 +}] \ No newline at end of file diff --git a/test/validator/samples/contenteditable-missing/input.svelte b/test/validator/samples/contenteditable-missing/input.svelte new file mode 100644 index 000000000000..47f125894a1b --- /dev/null +++ b/test/validator/samples/contenteditable-missing/input.svelte @@ -0,0 +1,4 @@ + +