@jxsuite/studio 0.25.1 → 0.25.2

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.
@@ -8,6 +8,7 @@
8
8
  import { html, render as litRender } from "lit-html";
9
9
  import { updateUi, rightPanel } from "../store";
10
10
  import { effect, effectScope } from "../reactivity";
11
+ import { createPanelScheduler, type PanelScheduler } from "./panel-scheduler";
11
12
  import { activeTab } from "../workspace/workspace";
12
13
  import { tabIcon } from "./activity-bar";
13
14
  import { eventsSidebarTemplate } from "./events-panel";
@@ -34,27 +35,7 @@ let _ctx: RightPanelCtx | null = null;
34
35
 
35
36
  let _scope: import("@vue/reactivity").EffectScope | null = null;
36
37
 
37
- let _rendering = false;
38
- let _scheduled = false;
39
- let _hasFocus = false;
40
-
41
- function _isTextInput(el: Element | null) {
42
- if (!el) return false;
43
- const tag = el.tagName.toLowerCase();
44
- if (tag === "input" || tag === "textarea") return true;
45
- if (tag === "sp-textfield" || tag === "sp-number-field" || tag === "sp-search") return true;
46
- if (el.shadowRoot?.activeElement) return _isTextInput(el.shadowRoot.activeElement);
47
- return false;
48
- }
49
-
50
- function _onFocusIn(e: FocusEvent) {
51
- _hasFocus = _isTextInput(e.target as Element);
52
- }
53
-
54
- function _onFocusOut() {
55
- _hasFocus = false;
56
- render();
57
- }
38
+ let _scheduler: PanelScheduler | null = null;
58
39
 
59
40
  /**
60
41
  * Mount the right panel.
@@ -65,8 +46,12 @@ export function mount(ctx: RightPanelCtx) {
65
46
  _ctx = ctx;
66
47
  mountAiPanel();
67
48
  registerRightPanelRender(render);
68
- rightPanel.addEventListener("focusin", _onFocusIn);
69
- rightPanel.addEventListener("focusout", _onFocusOut);
49
+ _scheduler = createPanelScheduler({
50
+ root: rightPanel,
51
+ render: _doRender,
52
+ blockWhile: isColorPopoverOpen,
53
+ });
54
+ _scheduler.bindFocus();
70
55
  _scope = effectScope();
71
56
  _scope.run(() => {
72
57
  effect(() => {
@@ -84,9 +69,7 @@ export function mount(ctx: RightPanelCtx) {
84
69
  void tab.session.ui.styleFilterActive;
85
70
  void tab.session.ui.inspectorSections;
86
71
 
87
- if (!_hasFocus && !isColorPopoverOpen()) {
88
- render();
89
- }
72
+ render();
90
73
  });
91
74
  });
92
75
  }
@@ -95,9 +78,8 @@ export function unmount() {
95
78
  _scope?.stop();
96
79
  _scope = null;
97
80
  _ctx = null;
98
- rightPanel.removeEventListener("focusin", _onFocusIn);
99
- rightPanel.removeEventListener("focusout", _onFocusOut);
100
- _hasFocus = false;
81
+ _scheduler?.unbind();
82
+ _scheduler = null;
101
83
  _propsContainer = null;
102
84
  _eventsContainer = null;
103
85
  _styleContainer = null;
@@ -105,15 +87,12 @@ export function unmount() {
105
87
  _lastTab = null;
106
88
  }
107
89
 
108
- let _rafId = 0;
109
-
90
+ /**
91
+ * Request a render. Coalesced and deferred while a text input in the panel is focused or a color
92
+ * popover is open (so explicit callers can never clobber a field mid-edit).
93
+ */
110
94
  export function render() {
111
- if (!_ctx) return;
112
- if (_rendering) return;
113
- if (!_scheduled) {
114
- _scheduled = true;
115
- _rafId = requestAnimationFrame(_flush);
116
- }
95
+ _scheduler?.schedule();
117
96
  }
118
97
 
119
98
  let _propsContainer: HTMLElement | null = null;
@@ -135,13 +114,8 @@ function _ensureContainers() {
135
114
  _assistantContainer.style.cssText = "display:flex;flex-direction:column;overflow:hidden";
136
115
  }
137
116
 
138
- function _flush() {
139
- _scheduled = false;
140
- _rafId = 0;
117
+ function _doRender() {
141
118
  if (!_ctx) return;
142
- if (_rendering) return;
143
- if (_hasFocus || isColorPopoverOpen()) return;
144
- _rendering = true;
145
119
  try {
146
120
  const ctx = _ctx as RightPanelCtx;
147
121
  const aTab = activeTab.value;
@@ -236,8 +210,6 @@ function _flush() {
236
210
  _lastTab = tab;
237
211
  } catch (e) {
238
212
  console.error("right-panel render error:", e);
239
- } finally {
240
- _rendering = false;
241
213
  }
242
214
  requestAnimationFrame(() => mountQuikChat());
243
215
  _ctx.updateForcedPseudoPreview();
@@ -20,6 +20,7 @@ import {
20
20
  mutateRenameDef,
21
21
  } from "../tabs/transact";
22
22
  import { renderFieldRow } from "../ui/field-row";
23
+ import { spTextField, rawTextArea } from "../ui/field-input";
23
24
  import { renderExpressionEditor, expressionHint } from "../ui/expression-editor";
24
25
  import { renderMediaPicker } from "../ui/media-picker";
25
26
  import type { TabUi } from "../tabs/tab";
@@ -293,16 +294,16 @@ export function signalFieldRow(label: string, value: string, onChange: (value: s
293
294
  prop: label,
294
295
  label,
295
296
  hasValue: false,
296
- widget: html`
297
- <sp-textfield
298
- size="s"
299
- .value=${value}
300
- @change=${(e: Event) => {
301
- const v = (e.target as HTMLInputElement).value;
302
- if (v !== value) onChange(v);
303
- }}
304
- ></sp-textfield>
305
- `,
297
+ // commitMode "blur": signal fields (rename, src, etc.) commit on blur/Enter only — a debounced
298
+ // mid-typing commit would, e.g., rename the signal on every keystroke pause.
299
+ widget: spTextField(
300
+ `sig:${label}`,
301
+ value,
302
+ (v: string) => {
303
+ if (v !== value) onChange(v);
304
+ },
305
+ { commitMode: "blur" },
306
+ ),
306
307
  });
307
308
  }
308
309
 
@@ -511,35 +512,23 @@ function renderSignalEditorTemplate(
511
512
  });
512
513
  };
513
514
 
514
- // Helper for textarea rows
515
+ // Helper for textarea rows — uses the shared draft layer (commits on blur/Enter and a 500ms
516
+ // debounce) so a panel re-render mid-edit can't truncate the in-progress text.
515
517
  const textareaRow = (
516
518
  label: string,
517
519
  value: string,
518
520
  onChange: (value: string) => void,
519
521
  opts: { minHeight?: string; mono?: boolean } = {},
520
522
  ) => {
521
- let debounce: ReturnType<typeof setTimeout> | undefined;
522
523
  return renderFieldRow({
523
524
  prop: label,
524
525
  label,
525
526
  hasValue: false,
526
- widget: html`
527
- <textarea
528
- class="field-input"
529
- style=${styleMap({
530
- minHeight: opts.minHeight || "40px",
531
- ...(opts.mono && {
532
- fontFamily: "'SF Mono','Fira Code','Consolas',monospace",
533
- fontSize: "11px",
534
- }),
535
- })}
536
- .value=${value}
537
- @input=${(e: Event) => {
538
- clearTimeout(debounce);
539
- debounce = setTimeout(() => onChange((e.target as HTMLInputElement).value), 500);
540
- }}
541
- ></textarea>
542
- `,
527
+ widget: rawTextArea(`sig:${label}`, value, onChange, {
528
+ debounceMs: 500,
529
+ ...(opts.minHeight != null && { minHeight: opts.minHeight }),
530
+ ...(opts.mono != null && { mono: opts.mono }),
531
+ }),
543
532
  });
544
533
  };
545
534
 
@@ -0,0 +1,254 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Field-input.ts — Controlled text-input widgets with a draft-state layer.
4
+ *
5
+ * The single input paradigm for every Studio panel. Each field is identified by a stable `key`.
6
+ * While a field is focused/edited, its in-progress text lives in a per-key DRAFT, decoupled from
7
+ * the document. The visible value is always `getFieldValue(key, committed)` bound via lit's `live`
8
+ * directive, so a render that slips through mid-edit never resets the field to a stale value.
9
+ *
10
+ * Commit semantics (consistent across all panels): commit a short debounce after typing pauses (so
11
+ * the canvas/preview updates live) AND immediately on blur or Enter (so the latest value is never
12
+ * lost). The draft is cleared on blur/Enter, after which the field reflects the committed (possibly
13
+ * normalized) document value again.
14
+ *
15
+ * Pair this with the focus-aware panel scheduler (panels/panel-scheduler.ts), which keeps the panel
16
+ * from re-rendering at all while a field is focused.
17
+ */
18
+
19
+ import { html } from "lit-html";
20
+ import type { TemplateResult } from "lit-html";
21
+ import { live } from "lit-html/directives/live.js";
22
+ import { keyed } from "lit-html/directives/keyed.js";
23
+
24
+ /** A lit-renderable value (template or directive result). */
25
+ type Renderable = unknown;
26
+
27
+ /** Default debounce (ms) before an in-progress edit is committed to the document. */
28
+ export const DEFAULT_DEBOUNCE_MS = 350;
29
+
30
+ interface DraftEntry {
31
+ value: string;
32
+ timer?: ReturnType<typeof setTimeout>;
33
+ }
34
+
35
+ /** Active per-field drafts, keyed by a stable field key. */
36
+ const _drafts = new Map<string, DraftEntry>();
37
+
38
+ /** The committed value to show for a field — the live draft while editing, else `committed`. */
39
+ export function getFieldValue(key: string, committed: string): string {
40
+ const d = _drafts.get(key);
41
+ return d ? d.value : committed;
42
+ }
43
+
44
+ /** Whether a draft is currently being edited for `key`. */
45
+ export function hasDraft(key: string): boolean {
46
+ return _drafts.has(key);
47
+ }
48
+
49
+ /** Record the in-progress value for a field without committing it. */
50
+ export function setDraft(key: string, value: string): void {
51
+ const d = _drafts.get(key);
52
+ if (d) d.value = value;
53
+ else _drafts.set(key, { value });
54
+ }
55
+
56
+ /** Discard a field's draft (and cancel any pending debounced commit). */
57
+ export function clearDraft(key: string): void {
58
+ const d = _drafts.get(key);
59
+ if (d?.timer) clearTimeout(d.timer);
60
+ _drafts.delete(key);
61
+ }
62
+
63
+ /**
64
+ * Schedule a debounced commit of the current draft, KEEPING the draft afterwards so the field stays
65
+ * controlled by the in-progress text until the user blurs. The committed value flows to the
66
+ * document (live preview) while the cursor/text stay untouched. Exported for unit testing.
67
+ */
68
+ export function scheduleDraftCommit(key: string, ms: number, commit: (v: string) => void): void {
69
+ const d = _drafts.get(key);
70
+ if (!d) return;
71
+ if (d.timer) clearTimeout(d.timer);
72
+ d.timer = setTimeout(() => {
73
+ const cur = _drafts.get(key);
74
+ if (!cur) return;
75
+ delete cur.timer;
76
+ commit(cur.value);
77
+ }, ms);
78
+ }
79
+
80
+ /**
81
+ * Flush a field's draft immediately (blur/Enter): commit the latest value and clear the draft so
82
+ * the field reflects the committed document value on the next render.
83
+ */
84
+ export function commitField(key: string, commit: (v: string) => void): void {
85
+ const d = _drafts.get(key);
86
+ if (!d) return;
87
+ if (d.timer) clearTimeout(d.timer);
88
+ const value = d.value;
89
+ _drafts.delete(key);
90
+ commit(value);
91
+ }
92
+
93
+ /** Shared option bag for the text widgets. */
94
+ export interface FieldOpts {
95
+ placeholder?: string;
96
+ size?: string;
97
+ debounceMs?: number;
98
+ /**
99
+ * When to commit to the document: - "live" (default): debounced while typing AND on blur/Enter
100
+ * (live preview). - "blur": only on blur/Enter — for fields where mid-typing commits are
101
+ * disruptive (e.g. renaming a signal, which would rename on every keystroke pause).
102
+ */
103
+ commitMode?: "live" | "blur";
104
+ /** Extra inline style for the control. */
105
+ style?: string;
106
+ /** Disable the control. */
107
+ disabled?: boolean;
108
+ }
109
+
110
+ interface TextAreaOpts extends FieldOpts {
111
+ minHeight?: string;
112
+ mono?: boolean;
113
+ }
114
+
115
+ function makeHandlers(
116
+ key: string,
117
+ ms: number,
118
+ commit: (v: string) => void,
119
+ commitMode: "live" | "blur" = "live",
120
+ ) {
121
+ return {
122
+ onInput: (e: Event) => {
123
+ const v = (e.target as HTMLInputElement).value;
124
+ setDraft(key, v);
125
+ if (commitMode === "live") scheduleDraftCommit(key, ms, commit);
126
+ },
127
+ onCommit: (e: Event) => {
128
+ const v = (e.target as HTMLInputElement).value;
129
+ setDraft(key, v);
130
+ commitField(key, commit);
131
+ },
132
+ onKeydown: (e: KeyboardEvent, multiline: boolean) => {
133
+ if (e.key === "Enter" && !multiline) {
134
+ commitField(key, commit);
135
+ }
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Single-line Spectrum text field bound to the draft layer.
142
+ *
143
+ * @param key Stable field identity (e.g. "head:og:title", "fm:title", "prop:0/1/className").
144
+ * @param value Committed value from the document.
145
+ * @param commit Writes the value to the document (transactDoc/mutate…).
146
+ */
147
+ export function spTextField(
148
+ key: string,
149
+ value: string,
150
+ commit: (v: string) => void,
151
+ opts: FieldOpts = {},
152
+ ): Renderable {
153
+ const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
154
+ const { onInput, onCommit, onKeydown } = makeHandlers(key, ms, commit, opts.commitMode);
155
+ return keyed(
156
+ key,
157
+ html`
158
+ <sp-textfield
159
+ size=${opts.size ?? "s"}
160
+ placeholder=${opts.placeholder ?? ""}
161
+ ?disabled=${!!opts.disabled}
162
+ style=${opts.style ?? ""}
163
+ .value=${live(getFieldValue(key, value))}
164
+ @input=${onInput}
165
+ @change=${onCommit}
166
+ @keydown=${(e: KeyboardEvent) => onKeydown(e, false)}
167
+ ></sp-textfield>
168
+ `,
169
+ );
170
+ }
171
+
172
+ /** Multiline Spectrum text field bound to the draft layer (Enter inserts a newline). */
173
+ export function spTextArea(
174
+ key: string,
175
+ value: string,
176
+ commit: (v: string) => void,
177
+ opts: FieldOpts = {},
178
+ ): Renderable {
179
+ const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
180
+ const { onInput, onCommit } = makeHandlers(key, ms, commit, opts.commitMode);
181
+ return keyed(
182
+ key,
183
+ html`
184
+ <sp-textfield
185
+ multiline
186
+ size=${opts.size ?? "s"}
187
+ placeholder=${opts.placeholder ?? ""}
188
+ ?disabled=${!!opts.disabled}
189
+ style=${opts.style ?? ""}
190
+ .value=${live(getFieldValue(key, value))}
191
+ @input=${onInput}
192
+ @change=${onCommit}
193
+ ></sp-textfield>
194
+ `,
195
+ );
196
+ }
197
+
198
+ /** Native `<textarea class="field-input">` bound to the draft layer (used by code-ish fields). */
199
+ export function rawTextArea(
200
+ key: string,
201
+ value: string,
202
+ commit: (v: string) => void,
203
+ opts: TextAreaOpts = {},
204
+ ): Renderable {
205
+ const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
206
+ const { onInput, onCommit } = makeHandlers(key, ms, commit, opts.commitMode);
207
+ const style = [
208
+ `min-height:${opts.minHeight ?? "40px"}`,
209
+ opts.mono ? "font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:11px" : "",
210
+ opts.style ?? "",
211
+ ]
212
+ .filter(Boolean)
213
+ .join(";");
214
+ return keyed(
215
+ key,
216
+ html`
217
+ <textarea
218
+ class="field-input"
219
+ style=${style}
220
+ placeholder=${opts.placeholder ?? ""}
221
+ ?disabled=${!!opts.disabled}
222
+ .value=${live(getFieldValue(key, value))}
223
+ @input=${onInput}
224
+ @change=${onCommit}
225
+ ></textarea>
226
+ `,
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Spectrum number field. Numbers are short and not prone to the typing-truncation bug, so this
232
+ * commits on change (blur/Enter) only — no draft buffering. `commit` receives the parsed number, or
233
+ * undefined when the field is cleared / invalid.
234
+ */
235
+ export function spNumberField(
236
+ value: number | undefined,
237
+ commit: (v: number | undefined) => void,
238
+ opts: FieldOpts & { hideStepper?: boolean; min?: number; max?: number } = {},
239
+ ): TemplateResult {
240
+ return html`
241
+ <sp-number-field
242
+ size=${opts.size ?? "s"}
243
+ ?hide-stepper=${opts.hideStepper ?? true}
244
+ ?disabled=${!!opts.disabled}
245
+ style=${opts.style ?? ""}
246
+ .value=${live(value !== undefined ? Number(value) : undefined)}
247
+ @change=${(e: Event) => {
248
+ const raw = (e.target as HTMLInputElement).value;
249
+ const n = Number(raw);
250
+ commit(raw === "" || isNaN(n) ? undefined : n);
251
+ }}
252
+ ></sp-number-field>
253
+ `;
254
+ }