From 450464f014cf2544e701f882b45df2dcf0a9c492 Mon Sep 17 00:00:00 2001 From: Vlad Glushchuk Date: Mon, 8 Apr 2019 20:28:16 +0200 Subject: [PATCH 1/4] Add bind:text and bind:html support for contenteditable elements Fixes #310 --- src/compiler/compile/nodes/Element.ts | 21 ++++++++- .../render-dom/wrappers/Element/Binding.ts | 16 +++++++ .../render-dom/wrappers/Element/index.ts | 6 +++ .../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/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 1b2d82188cd6..757157d9b4cb 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -605,7 +605,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/compiler/compile/render-dom/wrappers/Element/Binding.ts b/src/compiler/compile/render-dom/wrappers/Element/Binding.ts index 70123fa10c70..8eb5ba8097cb 100644 --- a/src/compiler/compile/render-dom/wrappers/Element/Binding.ts +++ b/src/compiler/compile/render-dom/wrappers/Element/Binding.ts @@ -195,6 +195,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};`; } @@ -307,6 +315,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/compiler/compile/render-dom/wrappers/Element/index.ts b/src/compiler/compile/render-dom/wrappers/Element/index.ts index 7d458481f1f8..a86b45dbb592 100644 --- a/src/compiler/compile/render-dom/wrappers/Element/index.ts +++ b/src/compiler/compile/render-dom/wrappers/Element/index.ts @@ -28,6 +28,12 @@ const events = [ node.name === 'textarea' || node.name === 'input' && !/radio|checkbox|range/.test(node.get_static_attribute_value('type') as string) }, + { + 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/compiler/compile/render-ssr/handlers/Element.ts b/src/compiler/compile/render-ssr/handlers/Element.ts index 5ed6875dec51..36ae121b539e 100644 --- a/src/compiler/compile/render-ssr/handlers/Element.ts +++ b/src/compiler/compile/render-ssr/handlers/Element.ts @@ -54,7 +54,12 @@ export default function(node: Element, renderer: Renderer, options: RenderOption slot_scopes: Map; }) { 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/); @@ -91,7 +96,7 @@ export default function(node: Element, renderer: Renderer, options: RenderOption 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 ( @@ -113,7 +118,7 @@ export default function(node: Element, renderer: Renderer, options: RenderOption 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 ( @@ -146,6 +151,14 @@ export default function(node: Element, renderer: Renderer, options: RenderOption 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 + ')}'; @@ -160,8 +173,8 @@ export default function(node: Element, renderer: Renderer, options: RenderOption 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 @@ + + From b1dc46d4f44d9d04f963f0829da4eb8cf5fd28f6 Mon Sep 17 00:00:00 2001 From: Vlad Glushchuk Date: Mon, 8 Apr 2019 20:56:52 +0200 Subject: [PATCH 2/4] Fix a typo --- src/compiler/compile/render-ssr/handlers/Element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/compile/render-ssr/handlers/Element.ts b/src/compiler/compile/render-ssr/handlers/Element.ts index 36ae121b539e..fc7f7df1a6d4 100644 --- a/src/compiler/compile/render-ssr/handlers/Element.ts +++ b/src/compiler/compile/render-ssr/handlers/Element.ts @@ -151,7 +151,7 @@ export default function(node: Element, renderer: Renderer, options: RenderOption if (name === 'group') { // TODO server-render group bindings - } else if (contenteditable && (node === 'text' || node === 'html')) { + } else if (contenteditable && (name === 'text' || name === 'html')) { const snippet = snip(expression) if (name == 'text') { node_contents = '${@escape(' + snippet + ')}' From 2cd66c0447cdcf7d1a0f42de513047d553467bcc Mon Sep 17 00:00:00 2001 From: Vlad Glushchuk Date: Sat, 20 Apr 2019 08:11:57 +0200 Subject: [PATCH 3/4] Fix the cursor reset issue --- src/compiler/compile/render-dom/wrappers/Element/Binding.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/compiler/compile/render-dom/wrappers/Element/Binding.ts b/src/compiler/compile/render-dom/wrappers/Element/Binding.ts index 8eb5ba8097cb..74a7bd86abc7 100644 --- a/src/compiler/compile/render-dom/wrappers/Element/Binding.ts +++ b/src/compiler/compile/render-dom/wrappers/Element/Binding.ts @@ -196,11 +196,11 @@ function get_dom_updater( } if (binding.node.name === 'text') { - return `${element.var}.textContent = ${binding.snippet};`; + return `if (${binding.snippet} !== ${element.var}.textContent) ${element.var}.textContent = ${binding.snippet};`; } if (binding.node.name === 'html') { - return `${element.var}.innerHTML = ${binding.snippet};`; + return `if (${binding.snippet} !== ${element.var}.innerHTML) ${element.var}.innerHTML = ${binding.snippet};`; } return `${element.var}.${binding.node.name} = ${binding.snippet};`; From f5dde02b9912287cdd4f53fd5af0935af80b57a0 Mon Sep 17 00:00:00 2001 From: Vlad Glushchuk Date: Thu, 6 Jun 2019 10:57:41 +0200 Subject: [PATCH 4/4] Rebase and fix linter warning --- src/compiler/compile/render-ssr/handlers/Element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/compile/render-ssr/handlers/Element.ts b/src/compiler/compile/render-ssr/handlers/Element.ts index fc7f7df1a6d4..8b901853d14b 100644 --- a/src/compiler/compile/render-ssr/handlers/Element.ts +++ b/src/compiler/compile/render-ssr/handlers/Element.ts @@ -58,7 +58,7 @@ export default function(node: Element, renderer: Renderer, options: RenderOption const contenteditable = ( node.name !== 'textarea' && node.name !== 'input' && - node.attributes.some((attribute: Node) => attribute.name === 'contenteditable') + node.attributes.some((attribute) => attribute.name === 'contenteditable') ); const slot = node.get_static_attribute_value('slot');