Merge branch 'main' into blockless

blockless
Rich Harris 2 years ago
commit 39a17d254d

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: improve $inspect handling of derived objects

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: make `bind_this` implementation more robust

@ -117,6 +117,7 @@
"itchy-kings-deliver", "itchy-kings-deliver",
"itchy-lions-wash", "itchy-lions-wash",
"itchy-terms-guess", "itchy-terms-guess",
"khaki-cooks-develop",
"khaki-mails-draw", "khaki-mails-draw",
"khaki-moose-arrive", "khaki-moose-arrive",
"kind-baboons-approve", "kind-baboons-approve",
@ -200,6 +201,7 @@
"rich-waves-mix", "rich-waves-mix",
"rotten-bags-type", "rotten-bags-type",
"rotten-buckets-develop", "rotten-buckets-develop",
"rotten-experts-relax",
"selfish-dragons-knock", "selfish-dragons-knock",
"selfish-tools-hide", "selfish-tools-hide",
"serious-kids-deliver", "serious-kids-deliver",
@ -242,6 +244,7 @@
"spotty-pens-agree", "spotty-pens-agree",
"spotty-rocks-destroy", "spotty-rocks-destroy",
"spotty-spiders-compare", "spotty-spiders-compare",
"spotty-turkeys-sparkle",
"stale-books-perform", "stale-books-perform",
"stale-comics-look", "stale-comics-look",
"stale-jeans-refuse", "stale-jeans-refuse",
@ -305,6 +308,7 @@
"witty-camels-warn", "witty-camels-warn",
"witty-steaks-dream", "witty-steaks-dream",
"witty-tomatoes-care", "witty-tomatoes-care",
"witty-years-crash" "witty-years-crash",
"yellow-taxis-double"
] ]
} }

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: tweak initial `bind:clientWidth/clientHeight/offsetWidth/offsetHeight` update timing

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: permit whitespace within template scripts

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: allow boolean `contenteditable` attribute

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: improve import event handler support

@ -1,5 +1,17 @@
# svelte # svelte
## 5.0.0-next.65
### Patch Changes
- fix: improve $inspect handling of derived objects ([#10584](https://github.com/sveltejs/svelte/pull/10584))
- fix: permit whitespace within template scripts ([#10591](https://github.com/sveltejs/svelte/pull/10591))
- fix: allow boolean `contenteditable` attribute ([#10590](https://github.com/sveltejs/svelte/pull/10590))
- fix: improve import event handler support ([#10592](https://github.com/sveltejs/svelte/pull/10592))
## 5.0.0-next.64 ## 5.0.0-next.64
### Patch Changes ### Patch Changes

@ -2,7 +2,7 @@
"name": "svelte", "name": "svelte",
"description": "Cybernetically enhanced web apps", "description": "Cybernetically enhanced web apps",
"license": "MIT", "license": "MIT",
"version": "5.0.0-next.64", "version": "5.0.0-next.65",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {

@ -371,10 +371,6 @@ const errors = {
// message: // message:
// "'contenteditable' attribute is required for textContent, innerHTML and innerText two-way bindings" // "'contenteditable' attribute is required for textContent, innerHTML and innerText two-way bindings"
// }, // },
// dynamic_contenteditable_attribute: {
// code: 'dynamic-contenteditable-attribute',
// message: "'contenteditable' attribute cannot be dynamic if element uses two-way binding"
// },
// textarea_duplicate_value: { // textarea_duplicate_value: {
// code: 'textarea-duplicate-value', // code: 'textarea-duplicate-value',
// message: // message:

@ -475,7 +475,7 @@ const validation = {
); );
if (!contenteditable) { if (!contenteditable) {
error(node, 'missing-contenteditable-attribute'); error(node, 'missing-contenteditable-attribute');
} else if (!is_text_attribute(contenteditable)) { } else if (!is_text_attribute(contenteditable) && contenteditable.value !== true) {
error(contenteditable, 'dynamic-contenteditable-attribute'); error(contenteditable, 'dynamic-contenteditable-attribute');
} }
} }

@ -35,6 +35,7 @@ import {
import { regex_is_valid_identifier } from '../../../patterns.js'; import { regex_is_valid_identifier } from '../../../patterns.js';
import { javascript_visitors_runes } from './javascript-runes.js'; import { javascript_visitors_runes } from './javascript-runes.js';
import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js'; import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js';
import { walk } from 'zimmerframe';
/** /**
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element
@ -978,24 +979,11 @@ function serialize_inline_component(node, component_name, context) {
if (bind_this !== null) { if (bind_this !== null) {
const prev = fn; const prev = fn;
const assignment = b.assignment('=', bind_this, b.id('$$value'));
const bind_this_id = /** @type {import('estree').Expression} */ (
// if expression is not an identifier, we know it can't be a signal
bind_this.type === 'Identifier'
? bind_this
: bind_this.type === 'MemberExpression' && bind_this.object.type === 'Identifier'
? bind_this.object
: undefined
);
fn = (node_id) => fn = (node_id) =>
b.call( serialize_bind_this(
'$.bind_this', /** @type {import('estree').Identifier | import('estree').MemberExpression} */ (bind_this),
prev(node_id), context,
b.arrow( prev(node_id)
[b.id('$$value')],
serialize_set_binding(assignment, context, () => context.visit(assignment))
),
bind_this_id
); );
} }
@ -1022,6 +1010,66 @@ function serialize_inline_component(node, component_name, context) {
return statements.length > 1 ? b.block(statements) : statements[0]; return statements.length > 1 ? b.block(statements) : statements[0];
} }
/**
* Serializes `bind:this` for components and elements.
* @param {import('estree').Identifier | import('estree').MemberExpression} bind_this
* @param {import('zimmerframe').Context<import('#compiler').SvelteNode, import('../types.js').ComponentClientTransformState>} context
* @param {import('estree').Expression} node
* @returns
*/
function serialize_bind_this(bind_this, context, node) {
let i = 0;
/** @type {Map<import('#compiler').Binding, [arg_idx: number, transformed: import('estree').Expression, expression: import('#compiler').Binding['expression']]>} */
const each_ids = new Map();
// Transform each reference to an each block context variable into a $$value_<i> variable
// by temporarily changing the `expression` of the corresponding binding.
// These $$value_<i> variables will be filled in by the bind_this runtime function through its last argument.
// Note that we only do this for each context variables, the consequence is that the value might be stale in
// some scenarios where the value is a member expression with changing computed parts or using a combination of multiple
// variables, but that was the same case in Svelte 4, too. Once legacy mode is gone completely, we can revisit this.
walk(
bind_this,
{},
{
Identifier(node) {
const binding = context.state.scope.get(node.name);
if (!binding || each_ids.has(binding)) return;
const associated_node = Array.from(context.state.scopes.entries()).find(
([_, scope]) => scope === binding?.scope
)?.[0];
if (associated_node?.type === 'EachBlock') {
each_ids.set(binding, [
i,
/** @type {import('estree').Expression} */ (context.visit(node)),
binding.expression
]);
binding.expression = b.id('$$value_' + i);
i++;
}
}
}
);
const bind_this_id = /** @type {import('estree').Expression} */ (context.visit(bind_this));
const ids = Array.from(each_ids.values()).map((id) => b.id('$$value_' + id[0]));
const assignment = b.assignment('=', bind_this, b.id('$$value'));
const update = serialize_set_binding(assignment, context, () => context.visit(assignment));
for (const [binding, [, , expression]] of each_ids) {
// reset expressions to what they were before
binding.expression = expression;
}
/** @type {import('estree').Expression[]} */
const args = [node, b.arrow([b.id('$$value'), ...ids], update), b.arrow([...ids], bind_this_id)];
if (each_ids.size) {
args.push(b.thunk(b.array(Array.from(each_ids.values()).map((id) => id[1]))));
}
return b.call('$.bind_this', ...args);
}
/** /**
* Creates a new block which looks roughly like this: * Creates a new block which looks roughly like this:
* ```js * ```js
@ -1314,6 +1362,7 @@ function serialize_event_handler(node, { state, visit }) {
binding !== null && binding !== null &&
(binding.kind === 'state' || (binding.kind === 'state' ||
binding.kind === 'frozen_state' || binding.kind === 'frozen_state' ||
binding.declaration_kind === 'import' ||
binding.kind === 'legacy_reactive' || binding.kind === 'legacy_reactive' ||
binding.kind === 'derived' || binding.kind === 'derived' ||
binding.kind === 'prop' || binding.kind === 'prop' ||
@ -2080,7 +2129,7 @@ export const template_visitors = {
node.fragment.nodes, node.fragment.nodes,
context.path, context.path,
child_metadata.namespace, child_metadata.namespace,
state.preserve_whitespace, node.name === 'script' || state.preserve_whitespace,
state.options.preserveComments state.options.preserveComments
); );
@ -2748,19 +2797,7 @@ export const template_visitors = {
} }
case 'this': case 'this':
call_expr = b.call( call_expr = serialize_bind_this(node.expression, context, state.node);
`$.bind_this`,
state.node,
setter,
/** @type {import('estree').Expression} */ (
// if expression is not an identifier, we know it can't be a signal
expression.type === 'Identifier'
? expression
: expression.type === 'MemberExpression' && expression.object.type === 'Identifier'
? expression.object
: undefined
)
);
break; break;
case 'textContent': case 'textContent':
case 'innerHTML': case 'innerHTML':

@ -30,7 +30,6 @@ import {
} from './reconciler.js'; } from './reconciler.js';
import { import {
destroy_signal, destroy_signal,
is_signal,
push_destroy_fn, push_destroy_fn,
execute_effect, execute_effect,
untrack, untrack,
@ -76,7 +75,6 @@ import { run } from '../common.js';
import { bind_transition, trigger_transitions } from './transitions.js'; import { bind_transition, trigger_transitions } from './transitions.js';
import { mutable_source, source } from './reactivity/sources.js'; import { mutable_source, source } from './reactivity/sources.js';
import { safe_equal, safe_not_equal } from './reactivity/equality.js'; import { safe_equal, safe_not_equal } from './reactivity/equality.js';
import { STATE_SYMBOL } from './constants.js';
/** @type {Set<string>} */ /** @type {Set<string>} */
const all_registerd_events = new Set(); const all_registerd_events = new Set();
@ -973,11 +971,11 @@ export function bind_resize_observer(dom, type, update) {
* @param {(size: number) => void} update * @param {(size: number) => void} update
*/ */
export function bind_element_size(dom, type, update) { export function bind_element_size(dom, type, update) {
// We need to wait a few ticks to be sure that the element has been inserted and rendered
// The alternative would be a mutation observer on the document but that's way to expensive
requestAnimationFrame(() => requestAnimationFrame(() => update(dom[type])));
const unsub = resize_observer_border_box.observe(dom, () => update(dom[type])); const unsub = resize_observer_border_box.observe(dom, () => update(dom[type]));
render_effect(() => unsub); effect(() => {
untrack(() => update(dom[type]));
return unsub;
});
} }
/** /**
@ -1322,39 +1320,48 @@ export function bind_prop(props, prop, value) {
} }
} }
/**
* @param {unknown} value
*/
function is_state_object(value) {
return value != null && typeof value === 'object' && STATE_SYMBOL in value;
}
/** /**
* @param {Element} element_or_component * @param {Element} element_or_component
* @param {(value: unknown) => void} update * @param {(value: unknown, ...parts: unknown[]) => void} update
* @param {import('./types.js').MaybeSignal} binding * @param {(...parts: unknown[]) => unknown} get_value
* @param {() => unknown[]} [get_parts] Set if the this binding is used inside an each block,
* returns all the parts of the each block context that are used in the expression
* @returns {void} * @returns {void}
*/ */
export function bind_this(element_or_component, update, binding) { export function bind_this(element_or_component, update, get_value, get_parts) {
render_effect(() => { /** @type {unknown[]} */
// If we are reading from a proxied state binding, then we don't need to untrack let old_parts;
// the update function as it will be fine-grain. /** @type {unknown[]} */
if (is_state_object(binding) || (is_signal(binding) && is_state_object(binding.v))) { let parts;
update(element_or_component);
} else { const e = effect(() => {
untrack(() => update(element_or_component)); old_parts = parts;
} // We only track changes to the parts, not the value itself to avoid unnecessary reruns.
return () => { parts = get_parts?.() || [];
// Defer to the next tick so that all updates can be reconciled first.
// This solves the case where one variable is shared across multiple this-bindings. untrack(() => {
render_effect(() => { if (element_or_component !== get_value(...parts)) {
untrack(() => { update(element_or_component, ...parts);
if (!is_signal(binding) || binding.v === element_or_component) { // If this is an effect rerun (cause: each block context changes), then nullfiy the binding at
update(null); // the previous position if it isn't already taken over by a different effect.
} if (old_parts && get_value(...old_parts) === element_or_component) {
}); update(null, ...old_parts);
}); }
}; }
});
});
// Add effect teardown (likely causes: if block became false, each item removed, component unmounted).
// In these cases we need to nullify the binding only if we detect that the value is still the same.
// If not, that means that another effect has now taken over the binding.
push_destroy_fn(e, () => {
// Defer to the next tick so that all updates can be reconciled first.
// This solves the case where one variable is shared across multiple this-bindings.
effect(() => {
if (get_value(...parts) === element_or_component) {
update(null, ...parts);
}
});
}); });
} }

@ -43,6 +43,7 @@ let is_micro_task_queued = false;
let is_flushing_effect = false; let is_flushing_effect = false;
// Used for $inspect // Used for $inspect
export let is_batching_effect = false; export let is_batching_effect = false;
let is_inspecting_signal = false;
// Handle effect queues // Handle effect queues
@ -130,8 +131,15 @@ export function batch_inspect(target, prop, receiver) {
return Reflect.apply(value, this, arguments); return Reflect.apply(value, this, arguments);
} finally { } finally {
is_batching_effect = previously_batching_effect; is_batching_effect = previously_batching_effect;
if (last_inspected_signal !== null) { if (last_inspected_signal !== null && !is_inspecting_signal) {
for (const fn of last_inspected_signal.inspect) fn(); is_inspecting_signal = true;
try {
for (const fn of last_inspected_signal.inspect) {
fn();
}
} finally {
is_inspecting_signal = false;
}
last_inspected_signal = null; last_inspected_signal = null;
} }
} }
@ -426,6 +434,7 @@ export function execute_effect(signal) {
function infinite_loop_guard() { function infinite_loop_guard() {
if (flush_count > 100) { if (flush_count > 100) {
flush_count = 0;
throw new Error( throw new Error(
'ERR_SVELTE_TOO_MANY_UPDATES' + 'ERR_SVELTE_TOO_MANY_UPDATES' +
(DEV (DEV

@ -6,5 +6,5 @@
* https://svelte.dev/docs/svelte-compiler#svelte-version * https://svelte.dev/docs/svelte-compiler#svelte-version
* @type {string} * @type {string}
*/ */
export const VERSION = '5.0.0-next.64'; export const VERSION = '5.0.0-next.65';
export const PUBLIC_VERSION = '5'; export const PUBLIC_VERSION = '5';

@ -0,0 +1,13 @@
import { test } from '../../assert';
import { log } from './log.js';
export default test({
async test({ assert }) {
await new Promise((fulfil) => setTimeout(fulfil, 0));
assert.deepEqual(log, [
[false, 0, 0],
[true, 100, 100]
]);
}
});

@ -0,0 +1,23 @@
<script>
import { log } from './log.js';
let w = 0;
let h = 0;
/** @type {HTMLElement} */
let div;
$: {
log.push([!!div, w, h]);
}
</script>
<div bind:this={div} bind:clientWidth={w} bind:clientHeight={h} class="box"></div>
<style>
.box {
width: 100px;
height: 100px;
background: #ff3e00;
}
</style>

@ -1,3 +1,4 @@
import { tick } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
import { create_deferred } from '../../../helpers.js'; import { create_deferred } from '../../../helpers.js';
@ -22,7 +23,8 @@ export default test({
deferred.resolve(42); deferred.resolve(42);
return deferred.promise return deferred.promise
.then(() => { .then(async () => {
await tick();
assert.htmlEqual(target.innerHTML, '<button>click me</button>'); assert.htmlEqual(target.innerHTML, '<button>click me</button>');
const { button } = component; const { button } = component;

@ -46,10 +46,8 @@ export default test({
component.items = ['foo', 'baz']; component.items = ['foo', 'baz'];
assert.equal(component.divs.length, 3, 'the divs array is still 3 long'); assert.equal(component.divs.length, 3, 'the divs array is still 3 long');
assert.equal(component.divs[2], null, 'the last div is unregistered'); assert.equal(component.divs[2], null, 'the last div is unregistered');
// Different from Svelte 3 assert.equal(component.ps[2], null, 'the last p is unregistered');
assert.equal(component.ps[1], null, 'the second p is unregistered'); assert.equal(component.spans['-bar1'], null, 'the bar span is unregistered');
// Different from Svelte 3
assert.equal(component.spans['-baz2'], null, 'the baz span is unregistered');
assert.equal(component.hrs.bar, null, 'the bar hr is unregistered'); assert.equal(component.hrs.bar, null, 'the bar hr is unregistered');
divs = target.querySelectorAll('div'); divs = target.querySelectorAll('div');
@ -58,22 +56,17 @@ export default test({
assert.equal(e, divs[i], `div ${i} is still correct`); assert.equal(e, divs[i], `div ${i} is still correct`);
}); });
// TODO: unsure if need these two tests to pass, as the logic between Svelte 3 spans = target.querySelectorAll('span');
// and Svelte 5 is different for these cases. // @ts-ignore
component.items.forEach((e, i) => {
// elems = target.querySelectorAll('span'); assert.equal(component.spans[`-${e}${i}`], spans[i], `span -${e}${i} is still correct`);
// component.items.forEach((e, i) => { });
// assert.equal(
// component.spans[`-${e}${i}`],
// elems[i],
// `span -${e}${i} is still correct`,
// );
// });
// elems = target.querySelectorAll('p'); ps = target.querySelectorAll('p');
// component.ps.forEach((e, i) => { // @ts-ignore
// assert.equal(e, elems[i], `p ${i} is still correct`); component.ps.forEach((e, i) => {
// }); assert.equal(e, ps[i], `p ${i} is still correct`);
});
hrs = target.querySelectorAll('hr'); hrs = target.querySelectorAll('hr');
// @ts-ignore // @ts-ignore

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
test({ assert, component, window }) {
document.dispatchEvent(new Event('DOMContentLoaded'));
assert.equal(window.document.querySelector('button')?.textContent, 'Hello world');
}
});

@ -0,0 +1,10 @@
<svelte:head>
<script>
// A comment
const val = 'Hello world';
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('button').textContent = val;
});
</script>
</svelte:head>
<button />

@ -1,3 +1,4 @@
import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -15,7 +16,8 @@ export default test({
assert.equal(window.getComputedStyle(div).opacity, '0'); assert.equal(window.getComputedStyle(div).opacity, '0');
raf.tick(600); raf.tick(600);
assert.equal(component.div, undefined);
assert.equal(target.querySelector('div'), undefined); assert.equal(target.querySelector('div'), undefined);
flushSync();
assert.equal(component.div, undefined);
} }
}); });

@ -0,0 +1,43 @@
import { tick } from 'svelte';
import { test } from '../../test';
/** @param {number | null} selected */
function get_html(selected) {
return `
<button>1</button>
<button>2</button>
<button>3</button>
<hr></hr>
${selected !== null ? `<div>${selected}</div>` : ''}
<hr></hr>
<p>${selected ?? '...'}</p>
`;
}
export default test({
html: get_html(null),
async test({ assert, target }) {
const [btn1, btn2, btn3] = target.querySelectorAll('button');
await btn1?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(1));
await btn2?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(2));
await btn1?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(1));
await btn3?.click();
await tick();
assert.htmlEqual(target.innerHTML, get_html(3));
}
});

@ -0,0 +1,30 @@
<script>
import { tick } from 'svelte';
let selected = $state(-1);
let current = $state();
let div; // explicitly no $state
</script>
{#each [1, 2, 3] as n, i}
<button
onclick={async () => {
selected = i;
await tick();
current = div?.textContent;
}}
>{n}</button>
{/each}
<hr />
{#each [1, 2, 3] as n, i}
{#if selected === i}
<div bind:this={div}>{n}</div>
{/if}
{/each}
<hr />
<p>{current ?? '...'}</p>

@ -0,0 +1,20 @@
import { test } from '../../test';
import { log, handler, log_a } from './event.js';
export default test({
before_test() {
log.length = 0;
handler.value = log_a;
},
async test({ assert, target }) {
const [b1, b2] = target.querySelectorAll('button');
b1?.click();
assert.deepEqual(log, ['a']);
b2?.click();
b1?.click();
assert.deepEqual(log, ['a', 'b']);
}
});

@ -0,0 +1,14 @@
/** @type {any[]} */
export const log = [];
export const log_a = () => {
log.push('a');
};
export const log_b = () => {
log.push('b');
};
export const handler = {
value: log_a
};

@ -0,0 +1,6 @@
<script>
import {handler, log_b} from './event.js';
</script>
<button onclick={handler.value}>click</button>
<button onclick={() => handler.value = log_b}>change</button>

@ -0,0 +1,54 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
/**
* @type {any[]}
*/
let log;
/**
* @type {typeof console.log}}
*/
let original_log;
export default test({
compileOptions: {
dev: true
},
before_test() {
log = [];
original_log = console.log;
console.log = (...v) => {
log.push(...v);
};
},
after_test() {
console.log = original_log;
},
async test({ assert, target }) {
const button = target.querySelector('button');
flushSync(() => {
button?.click();
});
assert.htmlEqual(target.innerHTML, `<button>update</button>\n1`);
assert.deepEqual(log, [
'init',
{
data: {
derived: 0,
list: []
},
derived: []
},
'update',
{
data: {
derived: 0,
list: [1]
},
derived: [1]
}
]);
}
});

@ -0,0 +1,21 @@
<script context="module">
const data = $state({
list: [],
derived: 0
});
const derived = $derived(data.list.filter(() => true));
const state = {
data,
get derived() { return derived }
};
</script>
<script>
data.list.length = 0;
$inspect(state);
</script>
<button onclick={() => (state.data.list.push(1))}>update</button>
{state.data.list}

@ -11,7 +11,7 @@ export default function Bind_this($$anchor, $$props) {
var fragment = $.comment($$anchor); var fragment = $.comment($$anchor);
var node = $.child_frag(fragment); var node = $.child_frag(fragment);
$.bind_this(Foo(node, {}), ($$value) => foo = $$value, foo); $.bind_this(Foo(node, {}), ($$value) => foo = $$value, () => foo);
$.close_frag($$anchor, fragment); $.close_frag($$anchor, fragment);
$.pop(); $.pop();
} }

@ -3,11 +3,11 @@
"code": "dynamic-contenteditable-attribute", "code": "dynamic-contenteditable-attribute",
"message": "'contenteditable' attribute cannot be dynamic if element uses two-way binding", "message": "'contenteditable' attribute cannot be dynamic if element uses two-way binding",
"start": { "start": {
"line": 6, "line": 11,
"column": 8 "column": 8
}, },
"end": { "end": {
"line": 6, "line": 11,
"column": 32 "column": 32
} }
} }

@ -3,4 +3,9 @@
let toggle = false; let toggle = false;
</script> </script>
<!-- ok -->
<editor contenteditable="true" bind:innerHTML={name}></editor>
<editor contenteditable="" bind:innerHTML={name}></editor>
<editor contenteditable bind:innerHTML={name}></editor>
<!-- error -->
<editor contenteditable={toggle} bind:innerHTML={name}></editor> <editor contenteditable={toggle} bind:innerHTML={name}></editor>

Loading…
Cancel
Save