Skip to content

Commit aa80da7

Browse files
authored
Merge pull request #2996 from sveltejs/init-contenteditable
Initialise html/text bindings from DOM
2 parents 21987c4 + 4e87553 commit aa80da7

File tree

39 files changed

+216
-63
lines changed

39 files changed

+216
-63
lines changed

site/content/docs/02-template-syntax.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,14 @@ When the value of an `<option>` matches its text content, the attribute can be o
510510
</select>
511511
```
512512

513+
---
514+
515+
Elements with the `contenteditable` attribute support `innerHTML` and `textContent` bindings.
516+
517+
```html
518+
<div contenteditable="true" bind:innerHTML={html}></div>
519+
```
520+
513521
##### Media element bindings
514522

515523
---
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script>
2+
let html = '<p>Write some text!</p>';
3+
</script>
4+
5+
<div contenteditable="true"></div>
6+
7+
<pre>{html}</pre>
8+
9+
<style>
10+
[contenteditable] {
11+
padding: 0.5em;
12+
border: 1px solid #eee;
13+
border-radius: 4px;
14+
}
15+
</style>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script>
2+
let html = '<p>Write some text!</p>';
3+
</script>
4+
5+
<div
6+
contenteditable="true"
7+
bind:innerHTML={html}
8+
></div>
9+
10+
<pre>{html}</pre>
11+
12+
<style>
13+
[contenteditable] {
14+
padding: 0.5em;
15+
border: 1px solid #eee;
16+
border-radius: 4px;
17+
}
18+
</style>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
title: Contenteditable bindings
3+
---
4+
5+
Elements with a `contenteditable="true"` attribute support `textContent` and `innerHTML` bindings:
6+
7+
```html
8+
<div
9+
contenteditable="true"
10+
bind:innerHTML={html}
11+
></div>
12+
```

src/compiler/compile/nodes/Element.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -608,8 +608,8 @@ export default class Element extends Node {
608608
});
609609
}
610610
} else if (
611-
name === 'text' ||
612-
name === 'html'
611+
name === 'textContent' ||
612+
name === 'innerHTML'
613613
) {
614614
const contenteditable = this.attributes.find(
615615
(attribute: Attribute) => attribute.name === 'contenteditable'
@@ -618,7 +618,7 @@ export default class Element extends Node {
618618
if (!contenteditable) {
619619
component.error(binding, {
620620
code: `missing-contenteditable-attribute`,
621-
message: `'contenteditable' attribute is required for text and html two-way bindings`
621+
message: `'contenteditable' attribute is required for textContent and innerHTML two-way bindings`
622622
});
623623
} else if (contenteditable && !contenteditable.is_static) {
624624
component.error(contenteditable, {

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

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ export default class BindingWrapper {
133133
break;
134134
}
135135

136+
case 'textContent':
137+
update_conditions.push(`${this.snippet} !== ${parent.var}.textContent`);
138+
break;
139+
140+
case 'innerHTML':
141+
update_conditions.push(`${this.snippet} !== ${parent.var}.innerHTML`);
142+
break;
143+
136144
case 'currentTime':
137145
case 'playbackRate':
138146
case 'volume':
@@ -162,7 +170,9 @@ export default class BindingWrapper {
162170
);
163171
}
164172

165-
if (!/(currentTime|paused)/.test(this.node.name)) {
173+
if (this.node.name === 'innerHTML' || this.node.name === 'textContent') {
174+
block.builders.mount.add_block(`if (${this.snippet} !== void 0) ${update_dom}`);
175+
} else if (!/(currentTime|paused)/.test(this.node.name)) {
166176
block.builders.mount.add_block(update_dom);
167177
}
168178
}
@@ -198,14 +208,6 @@ function get_dom_updater(
198208
return `${element.var}.checked = ${condition};`;
199209
}
200210

201-
if (binding.node.name === 'text') {
202-
return `if (${binding.snippet} !== ${element.var}.textContent) ${element.var}.textContent = ${binding.snippet};`;
203-
}
204-
205-
if (binding.node.name === 'html') {
206-
return `if (${binding.snippet} !== ${element.var}.innerHTML) ${element.var}.innerHTML = ${binding.snippet};`;
207-
}
208-
209211
return `${element.var}.${binding.node.name} = ${binding.snippet};`;
210212
}
211213

@@ -318,14 +320,6 @@ function get_value_from_dom(
318320
return `@time_ranges_to_array(this.${name})`;
319321
}
320322

321-
if (name === 'text') {
322-
return `this.textContent`;
323-
}
324-
325-
if (name === 'html') {
326-
return `this.innerHTML`;
327-
}
328-
329323
// everything else
330324
return `this.${name}`;
331325
}

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const events = [
3030
{
3131
event_names: ['input'],
3232
filter: (node: Element, name: string) =>
33-
(name === 'text' || name === 'html') &&
33+
(name === 'textContent' || name === 'innerHTML') &&
3434
node.attributes.some(attribute => attribute.name === 'contenteditable')
3535
},
3636
{
@@ -510,7 +510,19 @@ export default class ElementWrapper extends Wrapper {
510510
.map(binding => `${binding.snippet} === void 0`)
511511
.join(' || ');
512512

513-
if (this.node.name === 'select' || group.bindings.find(binding => binding.node.name === 'indeterminate' || binding.is_readonly_media_attribute())) {
513+
const should_initialise = (
514+
this.node.name === 'select' ||
515+
group.bindings.find(binding => {
516+
return (
517+
binding.node.name === 'indeterminate' ||
518+
binding.node.name === 'textContent' ||
519+
binding.node.name === 'innerHTML' ||
520+
binding.is_readonly_media_attribute()
521+
);
522+
})
523+
);
524+
525+
if (should_initialise) {
514526
const callback = has_local_function ? handler : `() => ${callee}.call(${this.var})`;
515527
block.builders.hydrate.add_line(
516528
`if (${some_initial_state_is_undefined}) @add_render_callback(${callback});`

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

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ export default function(node: Element, renderer: Renderer, options: RenderOption
5353
slot_scopes: Map<any, any>;
5454
}) {
5555
let opening_tag = `<${node.name}`;
56-
let node_contents; // awkward special case
56+
57+
// awkward special case
58+
let node_contents;
59+
let value;
60+
5761
const contenteditable = (
5862
node.name !== 'textarea' &&
5963
node.name !== 'input' &&
@@ -150,33 +154,34 @@ export default function(node: Element, renderer: Renderer, options: RenderOption
150154

151155
if (name === 'group') {
152156
// TODO server-render group bindings
153-
} else if (contenteditable && (name === 'text' || name === 'html')) {
154-
const snippet = snip(expression);
155-
if (name == 'text') {
156-
node_contents = '${@escape(' + snippet + ')}';
157-
} else {
158-
// Do not escape HTML content
159-
node_contents = '${' + snippet + '}';
160-
}
157+
} else if (contenteditable && (name === 'textContent' || name === 'innerHTML')) {
158+
node_contents = snip(expression);
159+
value = name === 'textContent' ? '@escape($$value)' : '$$value';
161160
} else if (binding.name === 'value' && node.name === 'textarea') {
162161
const snippet = snip(expression);
163-
node_contents='${(' + snippet + ') || ""}';
162+
node_contents = '${(' + snippet + ') || ""}';
164163
} else {
165164
const snippet = snip(expression);
166-
opening_tag += ' ${(v => v ? ("' + name + '" + (v === true ? "" : "=" + JSON.stringify(v))) : "")(' + snippet + ')}';
165+
opening_tag += '${@add_attribute("' + name + '", ' + snippet + ')}';
167166
}
168167
});
169168

170169
if (add_class_attribute) {
171-
opening_tag += `\${((v) => v ? ' class="' + v + '"' : '')([${class_expression}].join(' ').trim())}`;
170+
opening_tag += `\${@add_classes([${class_expression}].join(' ').trim())}`;
172171
}
173172

174173
opening_tag += '>';
175174

176175
renderer.append(opening_tag);
177176

178-
if ((node.name === 'textarea' || contenteditable) && node_contents !== undefined) {
179-
renderer.append(node_contents);
177+
if (node_contents !== undefined) {
178+
if (contenteditable) {
179+
renderer.append('${($$value => $$value === void 0 ? `');
180+
renderer.render(node.children, options);
181+
renderer.append('` : ' + value + ')(' + node_contents + ')}');
182+
} else {
183+
renderer.append(node_contents);
184+
}
180185
} else {
181186
renderer.render(node.children, options);
182187
}

src/runtime/internal/ssr.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,12 @@ export function get_store_value<T>(store: Readable<T>): T | undefined {
119119
store.subscribe(_ => value = _)();
120120
return value;
121121
}
122+
123+
export function add_attribute(name, value) {
124+
if (!value) return '';
125+
return ` ${name}${value === true ? '' : `=${JSON.stringify(value)}`}`;
126+
}
127+
128+
export function add_classes(classes) {
129+
return classes ? ` class="${classes}"` : ``;
130+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export default {
2+
html: `
3+
<editor contenteditable="true"><b>world</b></editor>
4+
<p>hello <b>world</b></p>
5+
`,
6+
7+
ssrHtml: `
8+
<editor contenteditable="true"><b>world</b></editor>
9+
<p>hello undefined</p>
10+
`,
11+
12+
async test({ assert, component, target, window }) {
13+
assert.equal(component.name, '<b>world</b>');
14+
15+
const el = target.querySelector('editor');
16+
17+
el.innerHTML = 'every<span>body</span>';
18+
19+
// No updates to data yet
20+
assert.htmlEqual(target.innerHTML, `
21+
<editor contenteditable="true">every<span>body</span></editor>
22+
<p>hello <b>world</b></p>
23+
`);
24+
25+
// Handle user input
26+
const event = new window.Event('input');
27+
await el.dispatchEvent(event);
28+
assert.htmlEqual(target.innerHTML, `
29+
<editor contenteditable="true">every<span>body</span></editor>
30+
<p>hello every<span>body</span></p>
31+
`);
32+
33+
component.name = 'good<span>bye</span>';
34+
assert.equal(el.innerHTML, 'good<span>bye</span>');
35+
assert.htmlEqual(target.innerHTML, `
36+
<editor contenteditable="true">good<span>bye</span></editor>
37+
<p>hello good<span>bye</span></p>
38+
`);
39+
},
40+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
export let name;
3+
</script>
4+
5+
<editor contenteditable="true" bind:innerHTML={name}>
6+
<b>world</b>
7+
</editor>
8+
<p>hello {@html name}</p>

test/runtime/samples/contenteditable-html/_config.js renamed to test/runtime/samples/binding-contenteditable-html/_config.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ export default {
88
<p>hello <b>world</b></p>
99
`,
1010

11-
ssrHtml: `
12-
<editor contenteditable="true"><b>world</b></editor>
13-
<p>hello <b>world</b></p>
14-
`,
15-
1611
async test({ assert, component, target, window }) {
1712
const el = target.querySelector('editor');
1813
assert.equal(el.innerHTML, '<b>world</b>');
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:innerHTML={name}></editor>
6+
<p>hello {@html name}</p>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export default {
2+
html: `
3+
<editor contenteditable="true"><b>world</b></editor>
4+
<p>hello world</p>
5+
`,
6+
7+
ssrHtml: `
8+
<editor contenteditable="true"><b>world</b></editor>
9+
<p>hello undefined</p>
10+
`,
11+
12+
async test({ assert, component, target, window }) {
13+
assert.equal(component.name, 'world');
14+
15+
const el = target.querySelector('editor');
16+
17+
const event = new window.Event('input');
18+
19+
el.textContent = 'everybody';
20+
await el.dispatchEvent(event);
21+
22+
assert.htmlEqual(target.innerHTML, `
23+
<editor contenteditable="true">everybody</editor>
24+
<p>hello everybody</p>
25+
`);
26+
27+
component.name = 'goodbye';
28+
assert.equal(el.textContent, 'goodbye');
29+
assert.htmlEqual(target.innerHTML, `
30+
<editor contenteditable="true">goodbye</editor>
31+
<p>hello goodbye</p>
32+
`);
33+
},
34+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
export let name;
3+
</script>
4+
5+
<editor contenteditable="true" bind:textContent={name}>
6+
<b>world</b>
7+
</editor>
8+
<p>hello {name}</p>

test/runtime/samples/contenteditable-text/_config.js renamed to test/runtime/samples/binding-contenteditable-text/_config.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ export default {
88
<p>hello world</p>
99
`,
1010

11-
ssrHtml: `
12-
<editor contenteditable="true">world</editor>
13-
<p>hello world</p>
14-
`,
15-
1611
async test({ assert, component, target, window }) {
1712
const el = target.querySelector('editor');
1813
assert.equal(el.textContent, 'world');
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:textContent={name}></editor>
6+
<p>hello {name}</p>

test/runtime/samples/contenteditable-html/main.svelte

Lines changed: 0 additions & 6 deletions
This file was deleted.

test/runtime/samples/contenteditable-text/main.svelte

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)