Skip to content

Commit 597af1e

Browse files
committed
Add bind:text and bind:html support for contenteditable elements
Fixes #310
1 parent 0fe4195 commit 597af1e

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
@@ -579,7 +579,26 @@ export default class Element extends Node {
579579
message: `'${binding.name}' is not a valid binding on void elements like <${this.name}>. Use a wrapper element instead`
580580
});
581581
}
582-
} else if (name !== 'this') {
582+
} else if (
583+
name === 'text' ||
584+
name === 'html'
585+
){
586+
const contenteditable = this.attributes.find(
587+
(attribute: Attribute) => attribute.name === 'contenteditable'
588+
);
589+
590+
if (!contenteditable) {
591+
component.error(binding, {
592+
code: `missing-contenteditable-attribute`,
593+
message: `'contenteditable' attribute is required for text and html two-way bindings`
594+
});
595+
} else if (contenteditable && !contenteditable.is_static) {
596+
component.error(contenteditable, {
597+
code: `dynamic-contenteditable-attribute`,
598+
message: `'contenteditable' attribute cannot be dynamic if element uses two-way binding`
599+
});
600+
}
601+
} else if (name !== 'this') {
583602
component.error(binding, {
584603
code: `invalid-binding`,
585604
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
const component = node.find_nearest(/InlineComponent/);
@@ -85,7 +90,7 @@ export default function(node, renderer, options) {
8590
args.push(snip(attribute.expression));
8691
} else {
8792
if (attribute.name === 'value' && node.name === 'textarea') {
88-
textarea_contents = stringify_attribute(attribute, true);
93+
node_contents = stringify_attribute(attribute, true);
8994
} else if (attribute.is_true) {
9095
args.push(`{ ${quote_name_if_necessary(attribute.name)}: true }`);
9196
} else if (
@@ -107,7 +112,7 @@ export default function(node, renderer, options) {
107112
if (attribute.type !== 'Attribute') return;
108113

109114
if (attribute.name === 'value' && node.name === 'textarea') {
110-
textarea_contents = stringify_attribute(attribute, true);
115+
node_contents = stringify_attribute(attribute, true);
111116
} else if (attribute.is_true) {
112117
opening_tag += ` ${attribute.name}`;
113118
} else if (
@@ -136,6 +141,14 @@ export default function(node, renderer, options) {
136141

137142
if (name === 'group') {
138143
// TODO server-render group bindings
144+
} else if (contenteditable && (node === 'text' || node === 'html')) {
145+
const snippet = snip(expression)
146+
if (name == 'text') {
147+
node_contents = '${@escape(' + snippet + ')}'
148+
} else {
149+
// Do not escape HTML content
150+
node_contents = '${' + snippet + '}'
151+
}
139152
} else {
140153
const snippet = snip(expression);
141154
opening_tag += ' ${(v => v ? ("' + name + '" + (v === true ? "" : "=" + JSON.stringify(v))) : "")(' + snippet + ')}';
@@ -150,8 +163,8 @@ export default function(node, renderer, options) {
150163

151164
renderer.append(opening_tag);
152165

153-
if (node.name === 'textarea' && textarea_contents !== undefined) {
154-
renderer.append(textarea_contents);
166+
if ((node.name === 'textarea' || contenteditable) && node_contents !== undefined) {
167+
renderer.append(node_contents);
155168
} else {
156169
renderer.render(node.children, options);
157170
}
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)