Skip to content

Commit d37e48e

Browse files
committed
Add bind:text and bind:html support for contenteditable elements
Fixes sveltejs#310
1 parent b9aa891 commit d37e48e

File tree

12 files changed

+192
-6
lines changed

12 files changed

+192
-6
lines changed

src/compile/nodes/Element.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,26 @@ export default class Element extends Node {
570570
message: `'${binding.name}' is not a valid binding on void elements like <${this.name}>. Use a wrapper element instead`
571571
});
572572
}
573-
} else if (name !== 'this') {
573+
} else if (
574+
name === 'text' ||
575+
name === 'html'
576+
){
577+
const contenteditable = this.attributes.find(
578+
(attribute: Attribute) => attribute.name === 'contenteditable'
579+
);
580+
581+
if (!contenteditable) {
582+
component.error(binding, {
583+
code: `missing-contenteditable-attribute`,
584+
message: `'contenteditable' attribute is required for text and html two-way bindings`
585+
});
586+
} else if (contenteditable && !contenteditable.is_static) {
587+
component.error(contenteditable, {
588+
code: `dynamic-contenteditable-attribute`,
589+
message: `'contenteditable' attribute cannot be dynamic if element uses two-way binding`
590+
});
591+
}
592+
} else if (name !== 'this') {
574593
component.error(binding, {
575594
code: `invalid-binding`,
576595
message: `'${binding.name}' is not a valid binding`

src/compile/render-dom/wrappers/Element/Binding.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ function get_dom_updater(
201201
return `${element.var}.checked = ${condition};`
202202
}
203203

204+
if (binding.node.name === 'text') {
205+
return `${element.var}.textContent = ${binding.snippet};`;
206+
}
207+
208+
if (binding.node.name === 'html') {
209+
return `${element.var}.innerHTML = ${binding.snippet};`;
210+
}
211+
204212
return `${element.var}.${binding.node.name} = ${binding.snippet};`;
205213
}
206214

@@ -313,6 +321,14 @@ function get_value_from_dom(
313321
return `@time_ranges_to_array(this.${name})`
314322
}
315323

324+
if (name === 'text') {
325+
return `this.textContent`;
326+
}
327+
328+
if (name === 'html') {
329+
return `this.innerHTML`;
330+
}
331+
316332
// everything else
317333
return `this.${name}`;
318334
}

src/compile/render-dom/wrappers/Element/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ const events = [
2929
node.name === 'textarea' ||
3030
node.name === 'input' && !/radio|checkbox|range/.test(node.get_static_attribute_value('type'))
3131
},
32+
{
33+
event_names: ['input'],
34+
filter: (node: Element, name: string) =>
35+
(name === 'text' || name === 'html') &&
36+
node.attributes.some(attribute => attribute.name === 'contenteditable')
37+
},
3238
{
3339
event_names: ['change'],
3440
filter: (node: Element, name: string) =>

src/compile/render-ssr/handlers/Element.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ const boolean_attributes = new Set([
4848

4949
export default function(node, renderer, options) {
5050
let opening_tag = `<${node.name}`;
51-
let textarea_contents; // awkward special case
51+
let node_contents; // awkward special case
52+
const contenteditable = (
53+
node.name !== 'textarea' &&
54+
node.name !== 'input' &&
55+
node.attributes.some((attribute: Node) => attribute.name === 'contenteditable')
56+
);
5257

5358
const slot = node.get_static_attribute_value('slot');
5459
if (slot && node.has_ancestor('InlineComponent')) {
@@ -77,7 +82,7 @@ export default function(node, renderer, options) {
7782
args.push(snip(attribute.expression));
7883
} else {
7984
if (attribute.name === 'value' && node.name === 'textarea') {
80-
textarea_contents = stringify_attribute(attribute, true);
85+
node_contents = stringify_attribute(attribute, true);
8186
} else if (attribute.is_true) {
8287
args.push(`{ ${quote_name_if_necessary(attribute.name)}: true }`);
8388
} else if (
@@ -99,7 +104,7 @@ export default function(node, renderer, options) {
99104
if (attribute.type !== 'Attribute') return;
100105

101106
if (attribute.name === 'value' && node.name === 'textarea') {
102-
textarea_contents = stringify_attribute(attribute, true);
107+
node_contents = stringify_attribute(attribute, true);
103108
} else if (attribute.is_true) {
104109
opening_tag += ` ${attribute.name}`;
105110
} else if (
@@ -128,6 +133,14 @@ export default function(node, renderer, options) {
128133

129134
if (name === 'group') {
130135
// TODO server-render group bindings
136+
} else if (contenteditable && (node === 'text' || node === 'html')) {
137+
const snippet = snip(expression)
138+
if (name == 'text') {
139+
node_contents = '${@escape(' + snippet + ')}'
140+
} else {
141+
// Do not escape HTML content
142+
node_contents = '${' + snippet + '}'
143+
}
131144
} else {
132145
const snippet = snip(expression);
133146
opening_tag += ' ${(v => v ? ("' + name + '" + (v === true ? "" : "=" + JSON.stringify(v))) : "")(' + snippet + ')}';
@@ -142,8 +155,8 @@ export default function(node, renderer, options) {
142155

143156
renderer.append(opening_tag);
144157

145-
if (node.name === 'textarea' && textarea_contents !== undefined) {
146-
renderer.append(textarea_contents);
158+
if ((node.name === 'textarea' || contenteditable) && node_contents !== undefined) {
159+
renderer.append(node_contents);
147160
} else {
148161
renderer.render(node.children, options);
149162
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export default {
2+
props: {
3+
name: '<b>world</b>',
4+
},
5+
6+
html: `
7+
<editor><b>world</b></editor>
8+
<p>hello <b>world</b></p>
9+
`,
10+
11+
ssrHtml: `
12+
<editor contenteditable="true"><b>world</b></editor>
13+
<p>hello <b>world</b></p>
14+
`,
15+
16+
async test({ assert, component, target, window }) {
17+
const el = target.querySelector('editor');
18+
assert.equal(el.innerHTML, '<b>world</b>');
19+
20+
el.innerHTML = 'every<span>body</span>';
21+
22+
// No updates to data yet
23+
assert.htmlEqual(target.innerHTML, `
24+
<editor>every<span>body</span></editor>
25+
<p>hello <b>world</b></p>
26+
`);
27+
28+
// Handle user input
29+
const event = new window.Event('input');
30+
await el.dispatchEvent(event);
31+
assert.htmlEqual(target.innerHTML, `
32+
<editor>every<span>body</span></editor>
33+
<p>hello every<span>body</span></p>
34+
`);
35+
36+
component.name = 'good<span>bye</span>';
37+
assert.equal(el.innerHTML, 'good<span>bye</span>');
38+
assert.htmlEqual(target.innerHTML, `
39+
<editor>good<span>bye</span></editor>
40+
<p>hello good<span>bye</span></p>
41+
`);
42+
},
43+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
export let name;
3+
</script>
4+
5+
<editor contenteditable="true" bind:html={name}></editor>
6+
<p>hello {@html name}</p>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export default {
2+
props: {
3+
name: 'world',
4+
},
5+
6+
html: `
7+
<editor>world</editor>
8+
<p>hello world</p>
9+
`,
10+
11+
ssrHtml: `
12+
<editor contenteditable="true">world</editor>
13+
<p>hello world</p>
14+
`,
15+
16+
async test({ assert, component, target, window }) {
17+
const el = target.querySelector('editor');
18+
assert.equal(el.textContent, 'world');
19+
20+
const event = new window.Event('input');
21+
22+
el.textContent = 'everybody';
23+
await el.dispatchEvent(event);
24+
25+
assert.htmlEqual(target.innerHTML, `
26+
<editor>everybody</editor>
27+
<p>hello everybody</p>
28+
`);
29+
30+
component.name = 'goodbye';
31+
assert.equal(el.textContent, 'goodbye');
32+
assert.htmlEqual(target.innerHTML, `
33+
<editor>goodbye</editor>
34+
<p>hello goodbye</p>
35+
`);
36+
},
37+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
export let name;
3+
</script>
4+
5+
<editor contenteditable="true" bind:text={name}></editor>
6+
<p>hello {name}</p>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[{
2+
"code": "dynamic-contenteditable-attribute",
3+
"message": "'contenteditable' attribute cannot be dynamic if element uses two-way binding",
4+
"start": {
5+
"line": 6,
6+
"column": 8,
7+
"character": 73
8+
},
9+
"end": {
10+
"line": 6,
11+
"column": 32,
12+
"character": 97
13+
},
14+
"pos": 73
15+
}]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
export let name;
3+
4+
let toggle = false;
5+
</script>
6+
<editor contenteditable={toggle} bind:html={name}></editor>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[{
2+
"code": "missing-contenteditable-attribute",
3+
"message": "'contenteditable' attribute is required for text and html two-way bindings",
4+
"start": {
5+
"line": 4,
6+
"column": 8,
7+
"character": 48
8+
},
9+
"end": {
10+
"line": 4,
11+
"column": 24,
12+
"character": 64
13+
},
14+
"pos": 48
15+
}]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<script>
2+
export let name;
3+
</script>
4+
<editor bind:text={name}></editor>

0 commit comments

Comments
 (0)