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: `
+
hello world
+ `, + + ssrHtml: ` +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, ` +hello world
+ `); + + // Handle user input + const event = new window.Event('input'); + await el.dispatchEvent(event); + assert.htmlEqual(target.innerHTML, ` +hello everybody
+ `); + + component.name = 'goodbye'; + assert.equal(el.innerHTML, 'goodbye'); + assert.htmlEqual(target.innerHTML, ` +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: ` +hello world
+ `, + + ssrHtml: ` +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, ` +hello everybody
+ `); + + component.name = 'goodbye'; + assert.equal(el.textContent, 'goodbye'); + assert.htmlEqual(target.innerHTML, ` +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 @@ + +