@adia-ai/web-components 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,8 @@
19
19
  --calendar-picker-trigger-fg: var(--a-ui-text-subtle);
20
20
  --calendar-picker-trigger-border: var(--a-ui-border);
21
21
  --calendar-picker-trigger-border-hover: var(--a-ui-border-hover);
22
- --calendar-picker-trigger-border-focus: var(--a-ui-border-active);
22
+ --calendar-picker-trigger-focus-ring: var(--a-focus-ring);
23
+ --calendar-picker-trigger-focus-ring-invalid: var(--a-focus-ring-invalid);
23
24
  --calendar-picker-trigger-placeholder-fg: var(--a-ui-text-placeholder);
24
25
  --calendar-picker-trigger-fg-disabled: var(--a-ui-text-disabled);
25
26
 
@@ -138,7 +139,12 @@
138
139
 
139
140
  :scope:focus-visible { outline: none; }
140
141
  :scope:focus-visible [slot="trigger"] {
141
- border-color: var(--calendar-picker-trigger-border-focus);
142
+ /* Canonical ring via L3 token (see semantics.css FOCUS block). */
143
+ box-shadow: var(--calendar-picker-trigger-focus-ring);
144
+ }
145
+ :scope[aria-invalid="true"]:focus-visible [slot="trigger"],
146
+ :scope[error]:focus-visible [slot="trigger"] {
147
+ box-shadow: var(--calendar-picker-trigger-focus-ring-invalid);
142
148
  }
143
149
 
144
150
  [slot="display"] {
@@ -11,10 +11,18 @@
11
11
  /* ── Colors ── */
12
12
  --chat-input-bg: var(--a-canvas-0);
13
13
  --chat-input-border: var(--a-border-subtle);
14
- --chat-input-border-focus: var(--a-fg);
15
14
  --chat-input-caret-color: var(--a-fg-subtle);
16
15
  --chat-input-border-disabled: var(--a-border-subtle);
17
16
 
17
+ /* Canonical focus ring — chat-input is a *nested-control host*.
18
+ See semantics.css FOCUS block + the nested-control pattern
19
+ note below. The inner textarea-ui suppresses its own ring
20
+ inside this @scope; the host paints the ring via
21
+ :focus-within so it wraps BOTH the editable area and the
22
+ toolbar as a single affordance. */
23
+ --chat-input-focus-ring: var(--a-focus-ring);
24
+ --chat-input-focus-ring-invalid: var(--a-focus-ring-invalid);
25
+
18
26
  /* ── Image preview ── */
19
27
  --chat-input-image-size: 3rem; /* 48px at d=1 */
20
28
  --chat-input-image-radius: var(--a-radius-sm);
@@ -42,11 +50,30 @@
42
50
  border: 1px solid var(--chat-input-border);
43
51
  border-radius: var(--chat-input-radius);
44
52
  background: var(--chat-input-bg);
45
- transition: border-color var(--chat-input-duration) var(--chat-input-easing);
46
53
  }
47
54
 
48
- :scope:has(textarea-ui :focus) {
49
- border-color: var(--chat-input-border-focus);
55
+ /* ── Nested-control focus pattern ────────────────────────────────────
56
+ chat-input-ui is a composite whose "primary surface" is the
57
+ combined textarea + toolbar, not the textarea alone. Focus should
58
+ wrap the whole composite, not just the inner input. Two rules
59
+ enforce that:
60
+
61
+ 1. `:scope:focus-within` paints the canonical ring around the
62
+ composite via box-shadow (matches every other form control).
63
+ 2. The inner textarea-ui's own focus treatments are suppressed
64
+ inside this @scope — the host owns the affordance.
65
+
66
+ The @scope block's containment is the signal: no data attribute
67
+ or explicit opt-in required. Any future composite that wants the
68
+ same pattern drops its inner control(s) into its @scope and
69
+ suppresses their focus rules with equivalent rules below.
70
+ ─────────────────────────────────────────────────────────────── */
71
+ :scope:focus-within {
72
+ box-shadow: var(--chat-input-focus-ring);
73
+ }
74
+ :scope[aria-invalid="true"]:focus-within,
75
+ :scope[error]:focus-within {
76
+ box-shadow: var(--chat-input-focus-ring-invalid);
50
77
  }
51
78
 
52
79
  /* Textarea: no border/bg of its own — container handles it. The
@@ -66,7 +93,16 @@
66
93
  padding: var(--chat-input-textarea-pt) var(--chat-input-textarea-px) 0;
67
94
  }
68
95
 
69
- textarea-ui [slot="text"]:focus {
96
+ /* Suppress the nested textarea-ui's own focus ring — the host (this
97
+ scope) owns the affordance. Required because textarea.css's default
98
+ rule paints a ring via box-shadow; without this override, the
99
+ composite would show both the host's outer ring AND the inner
100
+ control's ring simultaneously.
101
+
102
+ textarea.css's rule is `:scope:not([disabled]) [slot="text"]:focus`
103
+ with specificity (0,4,0); we need to beat that — using the same
104
+ `:not([disabled])` guard on the host lifts ours to (0,4,1). */
105
+ :scope textarea-ui:not([disabled]) [slot="text"]:focus {
70
106
  border: none;
71
107
  box-shadow: none;
72
108
  }
@@ -24,6 +24,39 @@
24
24
  /* ── Transition ── */
25
25
  --code-duration: var(--a-duration-fast);
26
26
  --code-easing: var(--a-easing);
27
+
28
+ /* ── Editor chrome (CodeMirror 6) ──
29
+ Active only when `[language]` triggers the CM mount. Still safe
30
+ to emit in read-only mode; unused until `.cm-editor` is in the
31
+ DOM. See docs/specs/code-editor.md §6. */
32
+ --code-gutter-bg: var(--a-bg-subtle);
33
+ --code-gutter-fg: var(--a-fg-muted);
34
+ --code-active-line-bg: color-mix(in oklch, var(--a-accent-muted) 40%, transparent);
35
+ --code-selection-bg: color-mix(in oklch, var(--a-accent-muted) 60%, transparent);
36
+ --code-selection-match: color-mix(in oklch, var(--a-accent-muted) 30%, transparent);
37
+ --code-cursor: var(--a-accent-strong);
38
+ --code-focus-ring: var(--a-focus-ring);
39
+
40
+ /* ── Syntax highlight tokens ──
41
+ Map Lezer/CodeMirror highlight tag names to AdiaUI semantic
42
+ tokens. Overridable per-instance by setting `--code-tok-*` on
43
+ any ancestor. See docs/specs/code-editor.md §6.3 for the tag →
44
+ class → role map. */
45
+ --code-tok-comment: var(--a-fg-subtle);
46
+ --code-tok-keyword: var(--a-accent-strong);
47
+ --code-tok-string: var(--a-success-strong);
48
+ --code-tok-number: var(--a-info-strong);
49
+ --code-tok-boolean: var(--a-info-strong);
50
+ --code-tok-operator: var(--a-fg);
51
+ --code-tok-punctuation: var(--a-fg-muted);
52
+ --code-tok-function: var(--a-brand-strong);
53
+ --code-tok-variable: var(--a-fg);
54
+ --code-tok-type: var(--a-warning-strong);
55
+ --code-tok-property: var(--a-fg);
56
+ --code-tok-tag: var(--a-accent-strong);
57
+ --code-tok-attribute: var(--a-warning-strong);
58
+ --code-tok-url: var(--a-info-strong);
59
+ --code-tok-invalid: var(--a-danger-strong);
27
60
  }
28
61
 
29
62
  /* ── Block (default) ── */
@@ -132,4 +165,117 @@
132
165
  font-size: 0.9em;
133
166
  overflow: visible;
134
167
  }
168
+
169
+ /* ── Bare mode — no chrome ──
170
+ Strips border, background, radius, and padding. Header is hidden
171
+ via #stampBlock (it's never added in bare). Used by the A2UI Editor
172
+ and any pane-level context where <code-ui> is framed by a parent
173
+ surface and should not carry its own. Height flows from the parent
174
+ container, so CM's internal scroll handles overflow. */
175
+ :scope[bare] {
176
+ border: none;
177
+ border-radius: 0;
178
+ background: transparent;
179
+ overflow: visible;
180
+ height: 100%;
181
+ }
182
+ :scope[bare] > pre {
183
+ padding: 0;
184
+ height: 100%;
185
+ }
186
+ :scope[bare] > [data-cm-mount] {
187
+ height: 100%;
188
+ }
189
+ :scope[bare] .cm-editor {
190
+ height: 100%;
191
+ }
192
+ :scope[bare] .cm-scroller {
193
+ padding: 0;
194
+ }
195
+ :scope[bare] .cm-gutters {
196
+ background: transparent;
197
+ }
198
+
199
+ /* ── CodeMirror mount ──
200
+ `<code-ui>` inserts `<div data-cm-mount>` in place of `<pre><code>`
201
+ when `[language]` triggers the CM lazy-load. Styles below apply to
202
+ the CodeMirror DOM that CM renders inside that mount.
203
+
204
+ Selector discipline: `.cm-*` classes are CodeMirror's; `.tok-*`
205
+ classes come from our `adiaHighlightStyle` (see core/_cm-theme.js).
206
+ CM's CSS-in-JS stylesheets load first + at lower specificity, so
207
+ these rules override without !important. */
208
+
209
+ > [data-cm-mount] {
210
+ display: block;
211
+ overflow: hidden;
212
+ }
213
+
214
+ :scope .cm-editor {
215
+ background: transparent;
216
+ color: var(--code-fg);
217
+ font-family: var(--code-font);
218
+ font-size: var(--code-font-size);
219
+ line-height: 1.5;
220
+ }
221
+
222
+ :scope .cm-scroller {
223
+ font-family: inherit;
224
+ line-height: inherit;
225
+ padding: var(--code-py) var(--code-px);
226
+ }
227
+
228
+ :scope .cm-content {
229
+ padding: 0;
230
+ caret-color: var(--code-cursor);
231
+ }
232
+
233
+ :scope .cm-editor.cm-focused {
234
+ outline: 2px solid var(--code-focus-ring);
235
+ outline-offset: -2px;
236
+ }
237
+
238
+ :scope .cm-gutters {
239
+ background: var(--code-gutter-bg);
240
+ color: var(--code-gutter-fg);
241
+ border-right: 1px solid var(--code-border);
242
+ }
243
+
244
+ :scope .cm-activeLine {
245
+ background: var(--code-active-line-bg);
246
+ }
247
+ :scope .cm-activeLineGutter {
248
+ background: var(--code-active-line-bg);
249
+ color: var(--code-fg);
250
+ }
251
+
252
+ :scope .cm-selectionBackground,
253
+ :scope ::selection {
254
+ background: var(--code-selection-bg);
255
+ }
256
+
257
+ :scope .cm-selectionMatch {
258
+ background: var(--code-selection-match);
259
+ }
260
+
261
+ :scope .cm-cursor {
262
+ border-left-color: var(--code-cursor);
263
+ }
264
+
265
+ /* Syntax highlight — one rule per token role (see core/_cm-theme.js) */
266
+ :scope .tok-comment { color: var(--code-tok-comment); font-style: italic; }
267
+ :scope .tok-keyword { color: var(--code-tok-keyword); font-weight: 500; }
268
+ :scope .tok-string { color: var(--code-tok-string); }
269
+ :scope .tok-number { color: var(--code-tok-number); }
270
+ :scope .tok-boolean { color: var(--code-tok-boolean); }
271
+ :scope .tok-operator { color: var(--code-tok-operator); }
272
+ :scope .tok-punctuation { color: var(--code-tok-punctuation); }
273
+ :scope .tok-function { color: var(--code-tok-function); }
274
+ :scope .tok-variable { color: var(--code-tok-variable); }
275
+ :scope .tok-type { color: var(--code-tok-type); font-weight: 500; }
276
+ :scope .tok-property { color: var(--code-tok-property); }
277
+ :scope .tok-tag { color: var(--code-tok-tag); }
278
+ :scope .tok-attribute { color: var(--code-tok-attribute); }
279
+ :scope .tok-url { color: var(--code-tok-url); text-decoration: underline; }
280
+ :scope .tok-invalid { color: var(--code-tok-invalid); text-decoration: wavy underline; }
135
281
  }
@@ -3,30 +3,61 @@
3
3
  * <code-ui language="css" inline>color: red</code-ui>
4
4
  * <code-ui language="json" text='{ "hello": "world" }'></code-ui>
5
5
  *
6
- * Inline or block code display with optional language label and copy button.
7
- * The body comes from (a) declarative children, or (b) the `text` attribute —
8
- * the `text` path is reactive, so setting `code.setAttribute('text', newText)`
9
- * updates the rendered code in place.
6
+ * Inline or block code display with optional language label and copy
7
+ * button. The body comes from (a) declarative children, or (b) the
8
+ * `text` attribute — the `text` path is reactive, so setting
9
+ * `code.setAttribute('text', newText)` updates the rendered code in place.
10
+ *
11
+ * When `[language]` names a supported language (see SUPPORTED_LANGUAGES),
12
+ * CodeMirror 6 is lazy-loaded and mounted in place of `<pre><code>` for
13
+ * syntax-highlighted read-only rendering. Phase 1 ships read-only only;
14
+ * `[editable]` lands in phase 2 (see docs/specs/code-editor.md §12).
10
15
  */
11
16
 
12
17
  import { AdiaElement } from '../../core/element.js';
13
18
 
19
+ const SUPPORTED_LANGUAGES = new Set([
20
+ 'json', 'html', 'javascript', 'js', 'css', 'markdown', 'md', 'yaml', 'yml',
21
+ ]);
22
+
23
+ // Normalize common language aliases to their canonical pack name.
24
+ const LANGUAGE_ALIAS = {
25
+ js: 'javascript',
26
+ md: 'markdown',
27
+ yml: 'yaml',
28
+ };
29
+
30
+ function canonicalLanguage(name) {
31
+ if (!name) return '';
32
+ const lower = String(name).toLowerCase();
33
+ return LANGUAGE_ALIAS[lower] ?? lower;
34
+ }
35
+
14
36
  class AdiaCode extends AdiaElement {
15
37
  static properties = {
16
38
  language: { type: String, default: '', reflect: true },
17
39
  inline: { type: Boolean, default: false, reflect: true },
18
40
  text: { type: String, default: '', reflect: true },
19
41
  lineNumbers: { type: Boolean, default: false, reflect: true, attribute: 'line-numbers' },
42
+ editable: { type: Boolean, default: false, reflect: true },
43
+ bare: { type: Boolean, default: false, reflect: true },
44
+ placeholder: { type: String, default: '', reflect: true },
20
45
  };
21
46
 
22
47
  static template = () => null;
23
48
 
24
49
  #copyBtn = null;
25
50
  #copyTimer = null;
51
+ #cmView = null; // EditorView instance when mounted
52
+ #mountGen = 0; // bumps on every connect/disconnect — guards against stale mounts
53
+ #pendingMount = false; // true while bundle/lang pack is loading
54
+ #focusSnapshot = null; // doc text captured on focus; used to diff for `change` event
26
55
 
27
56
  #onCopyClick = () => this.#copy();
28
57
 
29
58
  connected() {
59
+ this.#mountGen += 1;
60
+
30
61
  if (!this.inline && !this.querySelector(':scope > pre')) {
31
62
  this.#stampBlock();
32
63
  }
@@ -34,9 +65,20 @@ class AdiaCode extends AdiaElement {
34
65
  if (this.#copyBtn) {
35
66
  this.#copyBtn.addEventListener('click', this.#onCopyClick);
36
67
  }
68
+
69
+ // Mount CodeMirror when the language is supported OR the element is
70
+ // editable (editable plain-text is still useful). Inline instances
71
+ // stay on the static <code> path. Mount failures leave the static
72
+ // fallback in place — it's already visible.
73
+ const lang = canonicalLanguage(this.language);
74
+ const shouldMount = !this.inline && (SUPPORTED_LANGUAGES.has(lang) || this.editable);
75
+ if (shouldMount) {
76
+ this.#mountEditor();
77
+ }
37
78
  }
38
79
 
39
80
  disconnected() {
81
+ this.#mountGen += 1; // invalidate any in-flight mounts
40
82
  if (this.#copyTimer != null) {
41
83
  clearTimeout(this.#copyTimer);
42
84
  this.#copyTimer = null;
@@ -45,6 +87,10 @@ class AdiaCode extends AdiaElement {
45
87
  this.#copyBtn.removeEventListener('click', this.#onCopyClick);
46
88
  this.#copyBtn = null;
47
89
  }
90
+ if (this.#cmView) {
91
+ this.#cmView.destroy();
92
+ this.#cmView = null;
93
+ }
48
94
  }
49
95
 
50
96
  #stampBlock() {
@@ -54,20 +100,26 @@ class AdiaCode extends AdiaElement {
54
100
  const raw = (this.text || this.textContent || '').trim();
55
101
  this.textContent = '';
56
102
 
57
- // Header — semantic <header> with labelled children
58
- const header = document.createElement('header');
103
+ // Header — omitted in [bare] mode. Consumers that need zero chrome
104
+ // (the A2UI Editor's Schema + DOM views, an embedded editable surface
105
+ // in a pane, etc.) set [bare] to opt out.
106
+ if (!this.bare) {
107
+ const header = document.createElement('header');
59
108
 
60
- const label = document.createElement('span');
61
- label.setAttribute('slot', 'label');
62
- label.textContent = this.language || 'code';
63
- header.appendChild(label);
109
+ const label = document.createElement('span');
110
+ label.setAttribute('slot', 'label');
111
+ label.textContent = this.language || 'code';
112
+ header.appendChild(label);
64
113
 
65
- const copyBtn = document.createElement('div');
66
- copyBtn.setAttribute('role', 'button');
67
- copyBtn.setAttribute('tabindex', '0');
68
- copyBtn.setAttribute('slot', 'copy');
69
- copyBtn.textContent = 'Copy';
70
- header.appendChild(copyBtn);
114
+ const copyBtn = document.createElement('div');
115
+ copyBtn.setAttribute('role', 'button');
116
+ copyBtn.setAttribute('tabindex', '0');
117
+ copyBtn.setAttribute('slot', 'copy');
118
+ copyBtn.textContent = 'Copy';
119
+ header.appendChild(copyBtn);
120
+
121
+ this.appendChild(header);
122
+ }
71
123
 
72
124
  // Pre > Code — direct semantic elements, no slot attr
73
125
  const pre = document.createElement('pre');
@@ -75,14 +127,147 @@ class AdiaCode extends AdiaElement {
75
127
  code.textContent = raw;
76
128
  pre.appendChild(code);
77
129
 
78
- this.appendChild(header);
79
130
  this.appendChild(pre);
80
131
  }
81
132
 
82
- #copy() {
133
+ async #mountEditor() {
134
+ if (this.#pendingMount || this.#cmView) return;
135
+ const gen = this.#mountGen;
136
+ const lang = canonicalLanguage(this.language);
137
+ this.#pendingMount = true;
138
+
139
+ try {
140
+ const bundle = await import('../../core/code-editor-bundle.js');
141
+
142
+ // Load the language pack with a timeout. If it fails, continue with
143
+ // core-only (editor still renders; no syntax highlight). Emit an
144
+ // event so consumers can observe.
145
+ let extension = null;
146
+ try {
147
+ const mod = await bundle.importWithTimeout(bundle.languages[lang], `lang-${lang}`);
148
+ extension = mod?.extension ?? null;
149
+ } catch (err) {
150
+ this.dispatchEvent(new CustomEvent('language-load-error', {
151
+ bubbles: true, detail: { phase: 'language', language: lang, error: err },
152
+ }));
153
+ }
154
+
155
+ // Stale — component was disconnected (or reconnected elsewhere) while
156
+ // we were loading. Discard.
157
+ if (gen !== this.#mountGen || !this.isConnected) return;
158
+
159
+ this.#attachEditor(bundle, extension);
160
+ } catch (err) {
161
+ this.dispatchEvent(new CustomEvent('language-load-error', {
162
+ bubbles: true, detail: { phase: 'core', error: err },
163
+ }));
164
+ // Fallback <pre><code> stays visible — nothing to revert.
165
+ console.warn('[code-ui] CodeMirror failed to load; staying on static fallback.', err);
166
+ } finally {
167
+ this.#pendingMount = false;
168
+ }
169
+ }
170
+
171
+ #attachEditor(bundle, languageExtension) {
172
+ const {
173
+ EditorState, EditorView,
174
+ lineNumbers,
175
+ placeholder,
176
+ syntaxHighlighting,
177
+ history, historyKeymap, defaultKeymap, indentWithTab,
178
+ keymap,
179
+ adiaBaseTheme, adiaHighlightStyle,
180
+ } = bundle;
181
+
182
+ // Replace <pre><code> with a mount target. Keep <header> where it is.
183
+ const pre = this.querySelector(':scope > pre');
184
+ const prior = pre ? pre.querySelector('code')?.textContent ?? '' : '';
185
+ const initial = this.text || prior || '';
186
+
187
+ const mount = document.createElement('div');
188
+ mount.setAttribute('data-cm-mount', '');
189
+ if (pre) pre.replaceWith(mount);
190
+ else this.appendChild(mount);
191
+
192
+ const extensions = [
193
+ adiaBaseTheme,
194
+ syntaxHighlighting(adiaHighlightStyle),
195
+ ];
196
+
197
+ if (this.editable) {
198
+ extensions.push(
199
+ history(),
200
+ keymap.of([
201
+ { key: 'Mod-s', run: (v) => { this.#emitSave(v); return true; } },
202
+ ...defaultKeymap,
203
+ ...historyKeymap,
204
+ indentWithTab,
205
+ ]),
206
+ EditorView.updateListener.of((update) => {
207
+ if (!update.docChanged) return;
208
+ this.dispatchEvent(new CustomEvent('input', {
209
+ bubbles: true,
210
+ detail: { value: update.state.doc.toString() },
211
+ }));
212
+ }),
213
+ EditorView.domEventHandlers({
214
+ focus: (_, view) => {
215
+ this.#focusSnapshot = view.state.doc.toString();
216
+ return false;
217
+ },
218
+ blur: (_, view) => {
219
+ const current = view.state.doc.toString();
220
+ if (this.#focusSnapshot !== null && current !== this.#focusSnapshot) {
221
+ this.dispatchEvent(new CustomEvent('change', {
222
+ bubbles: true,
223
+ detail: { value: current },
224
+ }));
225
+ }
226
+ this.#focusSnapshot = null;
227
+ return false;
228
+ },
229
+ }),
230
+ );
231
+ } else {
232
+ extensions.push(EditorView.editable.of(false));
233
+ extensions.push(EditorState.readOnly.of(true));
234
+ }
235
+
236
+ if (languageExtension) extensions.push(languageExtension);
237
+ if (this.lineNumbers) extensions.push(lineNumbers());
238
+ if (this.placeholder) extensions.push(placeholder(this.placeholder));
239
+
240
+ this.#cmView = new EditorView({
241
+ parent: mount,
242
+ state: EditorState.create({ doc: initial, extensions }),
243
+ });
244
+ }
245
+
246
+ #emitSave(view) {
247
+ this.dispatchEvent(new CustomEvent('save', {
248
+ bubbles: true, cancelable: true,
249
+ detail: { value: view.state.doc.toString() },
250
+ }));
251
+ }
252
+
253
+ /** Live buffer. Reads from CodeMirror when mounted, else the static <code>. */
254
+ get value() {
255
+ if (this.#cmView) return this.#cmView.state.doc.toString();
83
256
  const code = this.querySelector(':scope > pre > code');
84
- if (!code) return;
85
- navigator.clipboard.writeText(code.textContent).then(() => {
257
+ return code?.textContent ?? this.text ?? '';
258
+ }
259
+
260
+ /** Convenience setter — identical to `text` but matches the form-control idiom. */
261
+ set value(v) {
262
+ this.text = String(v ?? '');
263
+ }
264
+
265
+ #copy() {
266
+ const text = this.#cmView
267
+ ? this.#cmView.state.doc.toString()
268
+ : (this.querySelector(':scope > pre > code')?.textContent ?? '');
269
+ if (!text) return;
270
+ navigator.clipboard.writeText(text).then(() => {
86
271
  const btn = this.querySelector(':scope > header [slot="copy"]');
87
272
  if (!btn) return;
88
273
  const prev = btn.textContent;
@@ -101,10 +286,22 @@ class AdiaCode extends AdiaElement {
101
286
 
102
287
  // Keep the rendered code in sync with the reactive `text` property so
103
288
  // callers can drive updates via setAttribute('text', ...).
104
- if (!this.inline) {
105
- const code = this.querySelector(':scope > pre > code');
106
- if (code && code.textContent !== this.text && this.text !== '') {
107
- code.textContent = this.text;
289
+ if (!this.inline && this.text !== '') {
290
+ if (this.#cmView) {
291
+ // Don't clobber in-flight edits while the editor has keyboard focus.
292
+ // Callers that want to force-replace can blur the editor first.
293
+ if (this.editable && this.#cmView.hasFocus) return;
294
+ const current = this.#cmView.state.doc.toString();
295
+ if (current !== this.text) {
296
+ this.#cmView.dispatch({
297
+ changes: { from: 0, to: current.length, insert: this.text },
298
+ });
299
+ }
300
+ } else {
301
+ const code = this.querySelector(':scope > pre > code');
302
+ if (code && code.textContent !== this.text) {
303
+ code.textContent = this.text;
304
+ }
108
305
  }
109
306
  }
110
307
  }