@jxsuite/studio 0.25.1 → 0.25.3
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.
- package/dist/studio.js +315 -237
- package/dist/studio.js.map +12 -9
- package/package.json +5 -5
- package/src/canvas/canvas-render.ts +39 -14
- package/src/panels/head-panel.ts +44 -82
- package/src/panels/left-panel.ts +14 -32
- package/src/panels/panel-scheduler.ts +126 -0
- package/src/panels/properties-panel.ts +32 -77
- package/src/panels/right-panel.ts +17 -45
- package/src/panels/signals-panel.ts +18 -29
- package/src/ui/field-input.ts +254 -0
|
@@ -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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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:
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
+
}
|