Skip to content

Commit

Permalink
feat: bind:innerText for contenteditable (sveltejs#4291)
Browse files Browse the repository at this point in the history
closes sveltejs#3311

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
  • Loading branch information
himynameisdave and dummdidumm committed Mar 16, 2023
1 parent a45afd5 commit f56fe33
Show file tree
Hide file tree
Showing 14 changed files with 198 additions and 33 deletions.
8 changes: 6 additions & 2 deletions elements/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,13 +534,17 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
is?: string | undefined | null;

/**
* Elements with the contenteditable attribute support innerHTML and textContent bindings.
* Elements with the contenteditable attribute support `innerHTML`, `textContent` and `innerText` bindings.
*/
'bind:innerHTML'?: string | undefined | null;
/**
* Elements with the contenteditable attribute support innerHTML and textContent bindings.
* Elements with the contenteditable attribute support `innerHTML`, `textContent` and `innerText` bindings.
*/
'bind:textContent'?: string | undefined | null;
/**
* Elements with the contenteditable attribute support `innerHTML`, `textContent` and `innerText` bindings.
*/
'bind:innerText'?: string | undefined | null;

// SvelteKit
'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null;
Expand Down
7 changes: 6 additions & 1 deletion site/content/docs/03-template-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,12 @@ When the value of an `<option>` matches its text content, the attribute can be o

---

Elements with the `contenteditable` attribute support `innerHTML` and `textContent` bindings.
Elements with the `contenteditable` attribute support the following bindings:
- [`innerHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML)
- [`innerText`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText)
- [`textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent)

There are slight differences between each of these, read more about them [here](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#Differences_from_innerText).

```sv
<div contenteditable="true" bind:innerHTML={html}></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
title: Contenteditable bindings
---

Elements with a `contenteditable="true"` attribute support `textContent` and `innerHTML` bindings:
Elements with the `contenteditable` attribute support the following bindings:
- [`innerHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML)
- [`innerText`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText)
- [`textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent)

There are slight differences between each of these, read more about them [here](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#Differences_from_innerText).

```html
<div
contenteditable="true"
bind:innerHTML={html}
></div>
```
```
2 changes: 1 addition & 1 deletion src/compiler/compile/compiler_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export default {
},
missing_contenteditable_attribute: {
code: 'missing-contenteditable-attribute',
message: '\'contenteditable\' attribute is required for textContent and innerHTML two-way bindings'
message: '\'contenteditable\' attribute is required for textContent, innerHTML and innerText two-way bindings'
},
dynamic_contenteditable_attribute: {
code: 'dynamic-contenteditable-attribute',
Expand Down
11 changes: 3 additions & 8 deletions src/compiler/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import StyleDirective from './StyleDirective';
import Text from './Text';
import { namespaces } from '../../utils/namespaces';
import map_children from './shared/map_children';
import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable';
import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character } from '../../utils/patterns';
import fuzzymatch from '../../utils/fuzzymatch';
import list from '../../utils/list';
Expand Down Expand Up @@ -1011,14 +1012,8 @@ export default class Element extends Node {
if (this.name !== 'img') {
return component.error(binding, compiler_errors.invalid_binding_element_with('<img>', name));
}
} else if (
name === 'textContent' ||
name === 'innerHTML'
) {
const contenteditable = this.attributes.find(
(attribute: Attribute) => attribute.name === 'contenteditable'
);

} else if (is_name_contenteditable(name)) {
const contenteditable = get_contenteditable_attr(this);
if (!contenteditable) {
return component.error(binding, compiler_errors.missing_contenteditable_attribute);
} else if (contenteditable && !contenteditable.is_static) {
Expand Down
5 changes: 5 additions & 0 deletions src/compiler/compile/render_dom/wrappers/Element/Binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ export default class BindingWrapper {
update_conditions.push(x`${this.snippet} !== ${parent.var}.textContent`);
mount_conditions.push(x`${this.snippet} !== void 0`);
break;

case 'innerText':
update_conditions.push(x`${this.snippet} !== ${parent.var}.innerText`);
mount_conditions.push(x`${this.snippet} !== void 0`);
break;

case 'innerHTML':
update_conditions.push(x`${this.snippet} !== ${parent.var}.innerHTML`);
Expand Down
18 changes: 8 additions & 10 deletions src/compiler/compile/render_dom/wrappers/Element/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Action from '../../../nodes/Action';
import MustacheTagWrapper from '../MustacheTag';
import RawMustacheTagWrapper from '../RawMustacheTag';
import is_dynamic from '../shared/is_dynamic';
import { is_name_contenteditable, has_contenteditable_attr } from '../../../utils/contenteditable';
import create_debugging_comment from '../shared/create_debugging_comment';
import { push_array } from '../../../../utils/push_array';

Expand All @@ -48,8 +49,8 @@ const events = [
{
event_names: ['input'],
filter: (node: Element, name: string) =>
(name === 'textContent' || name === 'innerHTML') &&
node.attributes.some(attribute => attribute.name === 'contenteditable')
is_name_contenteditable(name) &&
has_contenteditable_attr(node)
},
{
event_names: ['change'],
Expand Down Expand Up @@ -766,14 +767,11 @@ export default class ElementWrapper extends Wrapper {

const should_initialise = (
this.node.name === 'select' ||
binding_group.bindings.find(binding => {
return (
binding.node.name === 'indeterminate' ||
binding.node.name === 'textContent' ||
binding.node.name === 'innerHTML' ||
binding.is_readonly_media_attribute()
);
})
binding_group.bindings.find(binding => (
binding.node.name === 'indeterminate' ||
is_name_contenteditable(binding.node.name) ||
binding.is_readonly_media_attribute()
))
);

if (should_initialise) {
Expand Down
12 changes: 5 additions & 7 deletions src/compiler/compile/render_ssr/handlers/Element.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { is_void } from '../../../../shared/utils/names';
import { get_attribute_expression, get_attribute_value, get_class_attribute_value } from './shared/get_attribute_value';
import { boolean_attributes } from '../../../../shared/boolean_attributes';
import { is_name_contenteditable, is_contenteditable } from '../../utils/contenteditable';
import Renderer, { RenderOptions } from '../Renderer';
import Binding from '../../nodes/Binding';
import Element from '../../nodes/Element';
import { p, x } from 'code-red';
import Expression from '../../nodes/shared/Expression';
Expand All @@ -18,11 +20,7 @@ export default function (node: Element, renderer: Renderer, options: RenderOptio
// awkward special case
let node_contents;

const contenteditable = (
node.name !== 'textarea' &&
node.name !== 'input' &&
node.attributes.some((attribute) => attribute.name === 'contenteditable')
);
const contenteditable = is_contenteditable(node);

if (node.is_dynamic_element) {
renderer.push();
Expand Down Expand Up @@ -128,7 +126,7 @@ export default function (node: Element, renderer: Renderer, options: RenderOptio
}
}

node.bindings.forEach(binding => {
node.bindings.forEach((binding: Binding) => {
const { name, expression } = binding;

if (binding.is_readonly) {
Expand All @@ -144,7 +142,7 @@ export default function (node: Element, renderer: Renderer, options: RenderOptio
const condition = type === 'checkbox' ? x`~${bound}.indexOf(${value})` : x`${value} === ${bound}`;
renderer.add_expression(x`${condition} ? @add_attribute("checked", true, 1) : ""`);
}
} else if (contenteditable && (name === 'textContent' || name === 'innerHTML')) {
} else if (contenteditable && is_name_contenteditable(name)) {
node_contents = expression.node;

// TODO where was this used?
Expand Down
67 changes: 67 additions & 0 deletions src/compiler/compile/utils/__test__.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import * as assert from 'assert';
import get_name_from_filename from './get_name_from_filename';
import {
is_contenteditable,
has_contenteditable_attr,
is_name_contenteditable,
get_contenteditable_attr,
CONTENTEDITABLE_BINDINGS
} from './contenteditable';
import Element from '../nodes/Element';
import Attribute from '../nodes/Attribute';

describe('get_name_from_filename', () => {
it('uses the basename', () => {
Expand All @@ -20,3 +29,61 @@ describe('get_name_from_filename', () => {
assert.equal(get_name_from_filename('~.svelte'), '_');
});
});

describe('contenteditable', () => {
describe('is_contenteditable', () => {
it('returns false if node is input', () => {
const node = { name: 'input' } as Element;
assert.equal(is_contenteditable(node), false);
});
it('returns false if node is textarea', () => {
const node = { name: 'textarea' } as Element;
assert.equal(is_contenteditable(node), false);
});
it('returns false if node is not input or textarea AND it is not contenteditable', () => {
const attr = { name: 'href' } as Attribute;
const node = { name: 'a', attributes: [attr] } as Element;
assert.equal(is_contenteditable(node), false);
});
it('returns true if node is not input or textarea AND it is contenteditable', () => {
const attr = { name: 'contenteditable' } as Attribute;
const node = { name: 'a', attributes: [attr] } as Element;
assert.equal(is_contenteditable(node), true);
});
});

describe('has_contenteditable_attr', () => {
it('returns true if attribute is contenteditable', () => {
const attr = { name: 'contenteditable' } as Attribute;
const node = { attributes: [attr] } as Element;
assert.equal(has_contenteditable_attr(node), true);
});
it('returns false if attribute is not contenteditable', () => {
const attr = { name: 'href' } as Attribute;
const node = { attributes: [attr] } as Element;
assert.equal(has_contenteditable_attr(node), false);
});
});

describe('is_name_contenteditable', () => {
it('returns true if name is a contenteditable type', () => {
assert.equal(is_name_contenteditable(CONTENTEDITABLE_BINDINGS[0]), true);
});
it('returns false if name is not contenteditable type', () => {
assert.equal(is_name_contenteditable('value'), false);
});
});

describe('get_contenteditable_attr', () => {
it('returns the contenteditable Attribute if it exists', () => {
const attr = { name: 'contenteditable' } as Attribute;
const node = { name: 'div', attributes: [attr] } as Element;
assert.equal(get_contenteditable_attr(node), attr);
});
it('returns undefined if contenteditable attribute cannot be found', () => {
const node = { name: 'div', attributes: [] } as Element;
assert.equal(get_contenteditable_attr(node), undefined);
});
});

});
57 changes: 57 additions & 0 deletions src/compiler/compile/utils/contenteditable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Utilities for managing contenteditable nodes
import Attribute from '../nodes/Attribute';
import Element from '../nodes/Element';

export const CONTENTEDITABLE_BINDINGS = [
'textContent',
'innerHTML',
'innerText'
];

/**
* Returns true if node is an 'input' or 'textarea'.
* @param {Element} node The element to be checked
*/
function is_input_or_textarea(node: Element): boolean {
return node.name === 'textarea' || node.name === 'input';
}

/**
* Check if a given attribute is 'contenteditable'.
* @param {Attribute} attribute A node.attribute
*/
function is_attr_contenteditable(attribute: Attribute): boolean {
return attribute.name === 'contenteditable';
}

/**
* Check if any of a node's attributes are 'contentenditable'.
* @param {Element} node The element to be checked
*/
export function has_contenteditable_attr(node: Element): boolean {
return node.attributes.some(is_attr_contenteditable);
}

/**
* Returns true if node is not textarea or input, but has 'contenteditable' attribute.
* @param {Element} node The element to be tested
*/
export function is_contenteditable(node: Element): boolean {
return !is_input_or_textarea(node) && has_contenteditable_attr(node);
}

/**
* Returns true if a given binding/node is contenteditable.
* @param {string} name A binding or node name to be checked
*/
export function is_name_contenteditable(name: string): boolean {
return CONTENTEDITABLE_BINDINGS.includes(name);
}

/**
* Returns the contenteditable attribute from the node (if it exists).
* @param {Element} node The element to get the attribute from
*/
export function get_contenteditable_attr(node: Element): Attribute | undefined {
return node.attributes.find(is_attr_contenteditable);
}
25 changes: 25 additions & 0 deletions test/runtime/samples/binding-contenteditable-innertext/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export default {
props: {
name: 'world'
},

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

async test({ assert, component, target, window }) {
// JSDom doesn't support innerText yet, so the test is not ideal
// https://github.com/jsdom/jsdom/issues/1245
const el = target.querySelector('editor');
assert.equal(el.innerText, 'world');

const event = new window.Event('input');
el.innerText = 'everybody';
await el.dispatchEvent(event);
assert.equal(component.name, 'everybody');

component.name = 'goodbye';
assert.equal(el.innerText, 'goodbye');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
export let name;
</script>

<editor contenteditable="true" bind:innerText={name} />
<p>hello {name}</p>
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"code": "missing-contenteditable-attribute",
"message": "'contenteditable' attribute is required for textContent and innerHTML two-way bindings",
"message": "'contenteditable' attribute is required for textContent, innerHTML and innerText two-way bindings",
"start": {
"line": 6,
"column": 3
Expand Down
2 changes: 1 addition & 1 deletion test/validator/samples/contenteditable-missing/errors.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[{
"code": "missing-contenteditable-attribute",
"message": "'contenteditable' attribute is required for textContent and innerHTML two-way bindings",
"message": "'contenteditable' attribute is required for textContent, innerHTML and innerText two-way bindings",
"start": {
"line": 4,
"column": 8
Expand Down

0 comments on commit f56fe33

Please sign in to comment.