Skip to content

Commit

Permalink
Add bind:text and bind:html support for contenteditable elements
Browse files Browse the repository at this point in the history
  • Loading branch information
leporo committed Apr 8, 2019
1 parent b9aa891 commit d3a04d8
Show file tree
Hide file tree
Showing 12 changed files with 192 additions and 6 deletions.
21 changes: 20 additions & 1 deletion src/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,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`
Expand Down
16 changes: 16 additions & 0 deletions src/compile/render-dom/wrappers/Element/Binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};`;
}

Expand Down Expand Up @@ -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}`;
}
6 changes: 6 additions & 0 deletions src/compile/render-dom/wrappers/Element/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
23 changes: 18 additions & 5 deletions src/compile/render-ssr/handlers/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
if (slot && node.has_ancestor('InlineComponent')) {
Expand Down Expand Up @@ -77,7 +82,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 (
Expand All @@ -99,7 +104,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 (
Expand Down Expand Up @@ -128,6 +133,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 + ')}';
Expand All @@ -142,8 +155,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);
}
Expand Down
43 changes: 43 additions & 0 deletions test/runtime/samples/contenteditable-html/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export default {
props: {
name: '<b>world</b>',
},

html: `
<editor><b>world</b></editor>
<p>hello <b>world</b></p>
`,

ssrHtml: `
<editor contenteditable="true"><b>world</b></editor>
<p>hello <b>world</b></p>
`,

async test({ assert, component, target, window }) {
const el = target.querySelector('editor');
assert.equal(el.innerHTML, '<b>world</b>');

el.innerHTML = 'every<span>body</span>';

// No updates to data yet
assert.htmlEqual(target.innerHTML, `
<editor>every<span>body</span></editor>
<p>hello <b>world</b></p>
`);

// Handle user input
const event = new window.Event('input');
await el.dispatchEvent(event);
assert.htmlEqual(target.innerHTML, `
<editor>every<span>body</span></editor>
<p>hello every<span>body</span></p>
`);

component.name = 'good<span>bye</span>';
assert.equal(el.innerHTML, 'good<span>bye</span>');
assert.htmlEqual(target.innerHTML, `
<editor>good<span>bye</span></editor>
<p>hello good<span>bye</span></p>
`);
},
};
6 changes: 6 additions & 0 deletions test/runtime/samples/contenteditable-html/main.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
export let name;
</script>

<editor contenteditable="true" bind:html={name}></editor>
<p>hello {@html name}</p>
37 changes: 37 additions & 0 deletions test/runtime/samples/contenteditable-text/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export default {
props: {
name: 'world',
},

html: `
<editor>world</editor>
<p>hello world</p>
`,

ssrHtml: `
<editor contenteditable="true">world</editor>
<p>hello world</p>
`,

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, `
<editor>everybody</editor>
<p>hello everybody</p>
`);

component.name = 'goodbye';
assert.equal(el.textContent, 'goodbye');
assert.htmlEqual(target.innerHTML, `
<editor>goodbye</editor>
<p>hello goodbye</p>
`);
},
};
6 changes: 6 additions & 0 deletions test/runtime/samples/contenteditable-text/main.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
export let name;
</script>

<editor contenteditable="true" bind:text={name}></editor>
<p>hello {name}</p>
15 changes: 15 additions & 0 deletions test/validator/samples/contenteditable-dynamic/errors.json
Original file line number Diff line number Diff line change
@@ -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
}]
6 changes: 6 additions & 0 deletions test/validator/samples/contenteditable-dynamic/input.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
export let name;
let toggle = false;
</script>
<editor contenteditable={toggle} bind:html={name}></editor>
15 changes: 15 additions & 0 deletions test/validator/samples/contenteditable-missing/errors.json
Original file line number Diff line number Diff line change
@@ -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
}]
4 changes: 4 additions & 0 deletions test/validator/samples/contenteditable-missing/input.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<script>
export let name;
</script>
<editor bind:text={name}></editor>

0 comments on commit d3a04d8

Please sign in to comment.