@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.
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/studio",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "Jx Studio — visual builder for Jx documents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
|
|
34
|
-
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.
|
|
35
|
-
"@jxsuite/parser": "^0.25.
|
|
36
|
-
"@jxsuite/runtime": "^0.25.
|
|
37
|
-
"@jxsuite/schema": "^0.25.
|
|
34
|
+
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
|
|
35
|
+
"@jxsuite/parser": "^0.25.2",
|
|
36
|
+
"@jxsuite/runtime": "^0.25.2",
|
|
37
|
+
"@jxsuite/schema": "^0.25.2",
|
|
38
38
|
"@spectrum-web-components/accordion": "^1.12.1",
|
|
39
39
|
"@spectrum-web-components/action-bar": "1.12.1",
|
|
40
40
|
"@spectrum-web-components/action-button": "^1.12.1",
|
|
@@ -11,7 +11,7 @@ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
|
|
|
11
11
|
import { canvasWrap, canvasPanels, updateCanvas } from "../store";
|
|
12
12
|
import { activeTab } from "../workspace/workspace";
|
|
13
13
|
import { view } from "../view";
|
|
14
|
-
import { loadMarkdown } from "../files/file-ops";
|
|
14
|
+
import { loadMarkdown, serializeDocument } from "../files/file-ops";
|
|
15
15
|
import { renderWelcome } from "../panels/welcome-screen";
|
|
16
16
|
import { projectState } from "../state";
|
|
17
17
|
import {
|
|
@@ -75,6 +75,22 @@ export function initCanvasRender(ctx: CanvasRenderCtx) {
|
|
|
75
75
|
_ctx = ctx;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** Monaco language for the source view of a tab's document. */
|
|
79
|
+
function sourceLang(tab: import("../tabs/tab.js").Tab) {
|
|
80
|
+
if (tab.doc.sourceFormat === "md") return "markdown";
|
|
81
|
+
return (tab.documentPath || "").endsWith(".js") ? "javascript" : "json";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The full source text for the source view. Markdown files serialize to their on-disk form
|
|
86
|
+
* (frontmatter YAML — title, $head, etc. — plus the body), not just the JSON of the body tree.
|
|
87
|
+
*/
|
|
88
|
+
function sourceContent(tab: import("../tabs/tab.js").Tab, lang: string) {
|
|
89
|
+
if (lang === "markdown") return serializeDocument(tab);
|
|
90
|
+
if (lang === "javascript") return tab.doc.document?.toString?.() || "";
|
|
91
|
+
return JSON.stringify(tab.doc.document, null, 2);
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
export function renderCanvas() {
|
|
79
95
|
const tab = activeTab.value;
|
|
80
96
|
if (!tab) {
|
|
@@ -118,15 +134,15 @@ export function renderCanvas() {
|
|
|
118
134
|
view.functionEditor = null;
|
|
119
135
|
}
|
|
120
136
|
|
|
121
|
-
// Source mode: update existing Monaco editor without recreating
|
|
137
|
+
// Source mode: update existing Monaco editor without recreating. Don't replace the buffer while
|
|
138
|
+
// the user is actively typing in it — that would reformat under the cursor (the source view is
|
|
139
|
+
// the editing surface here, mirroring the panel draft-state behaviour).
|
|
122
140
|
if (canvasMode === "source" && view.monacoEditor) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
view.monacoEditor._ignoreNextChange = true;
|
|
129
|
-
view.monacoEditor.setValue(newVal);
|
|
141
|
+
const editor = view.monacoEditor;
|
|
142
|
+
const newVal = sourceContent(tab, sourceLang(tab));
|
|
143
|
+
if (!editor.hasTextFocus() && editor.getValue() !== newVal) {
|
|
144
|
+
editor._ignoreNextChange = true;
|
|
145
|
+
editor.setValue(newVal);
|
|
130
146
|
}
|
|
131
147
|
return;
|
|
132
148
|
}
|
|
@@ -234,9 +250,8 @@ export function renderCanvas() {
|
|
|
234
250
|
);
|
|
235
251
|
|
|
236
252
|
const filePath = tab.documentPath || "document.json";
|
|
237
|
-
const lang =
|
|
238
|
-
const content =
|
|
239
|
-
lang === "json" ? JSON.stringify(S.document, null, 2) : S.document?.toString?.() || "";
|
|
253
|
+
const lang = sourceLang(tab);
|
|
254
|
+
const content = sourceContent(tab, lang);
|
|
240
255
|
const modelUri = monaco.Uri.parse("file:///" + filePath);
|
|
241
256
|
const model = monaco.editor.createModel(content, lang, modelUri);
|
|
242
257
|
view.monacoEditor = monaco.editor.create(editorContainer as unknown as HTMLElement, {
|
|
@@ -262,10 +277,20 @@ export function renderCanvas() {
|
|
|
262
277
|
return;
|
|
263
278
|
}
|
|
264
279
|
clearTimeout(debounce);
|
|
265
|
-
debounce = setTimeout(() => {
|
|
280
|
+
debounce = setTimeout(async () => {
|
|
266
281
|
const tab = activeTab.value;
|
|
267
282
|
if (!tab) return;
|
|
268
|
-
if (lang === "
|
|
283
|
+
if (lang === "markdown") {
|
|
284
|
+
try {
|
|
285
|
+
// Parse the full markdown source back into body + frontmatter (title, $head, etc.).
|
|
286
|
+
const { document, frontmatter } = await loadMarkdown(editor.getValue());
|
|
287
|
+
tab.doc.document = document as JxMutableNode;
|
|
288
|
+
tab.doc.content.frontmatter = frontmatter;
|
|
289
|
+
tab.doc.dirty = true;
|
|
290
|
+
} catch {
|
|
291
|
+
// Unparseable markdown — don't update state
|
|
292
|
+
}
|
|
293
|
+
} else if (lang === "json") {
|
|
269
294
|
try {
|
|
270
295
|
tab.doc.document = JSON.parse(editor.getValue());
|
|
271
296
|
tab.doc.dirty = true;
|
package/src/panels/head-panel.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import { html, nothing } from "lit-html";
|
|
10
10
|
import { live } from "lit-html/directives/live.js";
|
|
11
11
|
import { renderFieldRow } from "../ui/field-row";
|
|
12
|
+
import { spTextField, spTextArea, spNumberField } from "../ui/field-input";
|
|
12
13
|
import { renderMediaPicker } from "../ui/media-picker";
|
|
13
|
-
import {
|
|
14
|
+
import { renderOnly, projectState } from "../store";
|
|
14
15
|
import type { JsonValue, DirEntry } from "../types";
|
|
15
16
|
import { activeTab } from "../workspace/workspace";
|
|
16
17
|
import { transactDoc, mutateUpdateFrontmatter } from "../tabs/transact";
|
|
@@ -237,32 +238,13 @@ function renderMetaFieldRow(
|
|
|
237
238
|
});
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
const commit = (v: string) =>
|
|
242
|
+
applyMutation((d: JxMutableNode) => upsertMeta(d, field.attr, field.key, v.trim()));
|
|
243
|
+
const placeholder =
|
|
244
|
+
field.key === "viewport" ? "width=device-width, initial-scale=1" : `${field.label}…`;
|
|
240
245
|
const widget = field.multiline
|
|
241
|
-
?
|
|
242
|
-
|
|
243
|
-
size="s"
|
|
244
|
-
multiline
|
|
245
|
-
.value=${live(val)}
|
|
246
|
-
placeholder="${field.label}…"
|
|
247
|
-
@input=${debouncedStyleCommit(`head:${field.key}`, 400, (e: Event) => {
|
|
248
|
-
const content = (e.target as HTMLInputElement).value?.trim() ?? "";
|
|
249
|
-
applyMutation((d: JxMutableNode) => upsertMeta(d, field.attr, field.key, content));
|
|
250
|
-
})}
|
|
251
|
-
></sp-textfield>
|
|
252
|
-
`
|
|
253
|
-
: html`
|
|
254
|
-
<sp-textfield
|
|
255
|
-
size="s"
|
|
256
|
-
.value=${live(val)}
|
|
257
|
-
placeholder=${field.key === "viewport"
|
|
258
|
-
? "width=device-width, initial-scale=1"
|
|
259
|
-
: `${field.label}…`}
|
|
260
|
-
@input=${debouncedStyleCommit(`head:${field.key}`, 400, (e: Event) => {
|
|
261
|
-
const content = (e.target as HTMLInputElement).value?.trim() ?? "";
|
|
262
|
-
applyMutation((d: JxMutableNode) => upsertMeta(d, field.attr, field.key, content));
|
|
263
|
-
})}
|
|
264
|
-
></sp-textfield>
|
|
265
|
-
`;
|
|
246
|
+
? spTextArea(`head:${field.key}`, val, commit, { placeholder: `${field.label}…` })
|
|
247
|
+
: spTextField(`head:${field.key}`, val, commit, { placeholder });
|
|
266
248
|
|
|
267
249
|
return renderFieldRow({
|
|
268
250
|
prop: field.key,
|
|
@@ -396,20 +378,17 @@ export function renderHeadTemplate({
|
|
|
396
378
|
applyMutation((d: JxMutableNode) => {
|
|
397
379
|
delete d.title;
|
|
398
380
|
}),
|
|
399
|
-
widget:
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
})}
|
|
411
|
-
></sp-textfield>
|
|
412
|
-
`,
|
|
381
|
+
widget: spTextField(
|
|
382
|
+
"head:title",
|
|
383
|
+
title,
|
|
384
|
+
(v: string) =>
|
|
385
|
+
applyMutation((d: JxMutableNode) => {
|
|
386
|
+
const val = v.trim();
|
|
387
|
+
if (val) d.title = val;
|
|
388
|
+
else delete d.title;
|
|
389
|
+
}),
|
|
390
|
+
{ placeholder: "Page title…" },
|
|
391
|
+
),
|
|
413
392
|
})}
|
|
414
393
|
${PAGE_FIELDS.map((field) => renderMetaFieldRow(field, head, applyMutation))}
|
|
415
394
|
${renderFieldRow({
|
|
@@ -620,28 +599,26 @@ function renderFmField(
|
|
|
620
599
|
}
|
|
621
600
|
|
|
622
601
|
if (entry.type === "array") {
|
|
623
|
-
const display = Array.isArray(value) ? value.join(", ") : value || "";
|
|
602
|
+
const display = Array.isArray(value) ? value.join(", ") : (value as string) || "";
|
|
624
603
|
return renderFieldRow({
|
|
625
604
|
prop: field,
|
|
626
605
|
label: displayLabel,
|
|
627
606
|
hasValue: hasVal,
|
|
628
607
|
onClear,
|
|
629
|
-
widget:
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
></sp-textfield>
|
|
644
|
-
`,
|
|
608
|
+
widget: spTextField(
|
|
609
|
+
`fm:${field}`,
|
|
610
|
+
display,
|
|
611
|
+
(v: string) => {
|
|
612
|
+
const arr = v
|
|
613
|
+
? v
|
|
614
|
+
.split(",")
|
|
615
|
+
.map((s: string) => s.trim())
|
|
616
|
+
.filter(Boolean)
|
|
617
|
+
: undefined;
|
|
618
|
+
transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, arr));
|
|
619
|
+
},
|
|
620
|
+
{ placeholder: "comma, separated" },
|
|
621
|
+
),
|
|
645
622
|
});
|
|
646
623
|
}
|
|
647
624
|
|
|
@@ -684,19 +661,9 @@ function renderFmField(
|
|
|
684
661
|
label: displayLabel,
|
|
685
662
|
hasValue: hasVal,
|
|
686
663
|
onClear,
|
|
687
|
-
widget:
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
hide-stepper
|
|
691
|
-
.value=${live(value !== undefined ? Number(value) : undefined)}
|
|
692
|
-
@change=${debouncedStyleCommit(`fm:${field}`, 400, (e: Event) => {
|
|
693
|
-
const v = (e.target as HTMLInputElement).value;
|
|
694
|
-
transactDoc(activeTab.value, (t) =>
|
|
695
|
-
mutateUpdateFrontmatter(t, field, isNaN(Number(v)) ? undefined : Number(v)),
|
|
696
|
-
);
|
|
697
|
-
})}
|
|
698
|
-
></sp-number-field>
|
|
699
|
-
`,
|
|
664
|
+
widget: spNumberField(value !== undefined ? Number(value) : undefined, (n) =>
|
|
665
|
+
transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, n)),
|
|
666
|
+
),
|
|
700
667
|
});
|
|
701
668
|
}
|
|
702
669
|
|
|
@@ -705,17 +672,12 @@ function renderFmField(
|
|
|
705
672
|
label: displayLabel,
|
|
706
673
|
hasValue: hasVal,
|
|
707
674
|
onClear,
|
|
708
|
-
widget:
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
.value
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
mutateUpdateFrontmatter(t, field, (e.target as HTMLInputElement).value || undefined),
|
|
716
|
-
);
|
|
717
|
-
})}
|
|
718
|
-
></sp-textfield>
|
|
719
|
-
`,
|
|
675
|
+
widget: spTextField(
|
|
676
|
+
`fm:${field}`,
|
|
677
|
+
(value as string) || "",
|
|
678
|
+
(v: string) =>
|
|
679
|
+
transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, v || undefined)),
|
|
680
|
+
{ placeholder: entry.format === "date" ? "YYYY-MM-DD" : "" },
|
|
681
|
+
),
|
|
720
682
|
});
|
|
721
683
|
}
|
package/src/panels/left-panel.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { html, render as litRender, nothing } from "lit-html";
|
|
|
11
11
|
import type { TemplateResult } from "lit-html";
|
|
12
12
|
import { leftPanel, updateSession } from "../store";
|
|
13
13
|
import { effect, effectScope } from "../reactivity";
|
|
14
|
+
import { createPanelScheduler, type PanelScheduler } from "./panel-scheduler";
|
|
14
15
|
import { activeTab } from "../workspace/workspace";
|
|
15
16
|
import { view } from "../view";
|
|
16
17
|
import { transact, mutateUpdateFrontmatter } from "../tabs/transact";
|
|
@@ -49,18 +50,7 @@ let _ctx: LeftPanelCtx | null = null;
|
|
|
49
50
|
|
|
50
51
|
let _scope: import("@vue/reactivity").EffectScope | null = null;
|
|
51
52
|
|
|
52
|
-
let
|
|
53
|
-
let _scheduled = false;
|
|
54
|
-
let _hasFocus = false;
|
|
55
|
-
|
|
56
|
-
function _onFocusIn() {
|
|
57
|
-
_hasFocus = true;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function _onFocusOut() {
|
|
61
|
-
_hasFocus = false;
|
|
62
|
-
render();
|
|
63
|
-
}
|
|
53
|
+
let _scheduler: PanelScheduler | null = null;
|
|
64
54
|
|
|
65
55
|
/**
|
|
66
56
|
* Mount the left panel orchestrator.
|
|
@@ -69,8 +59,8 @@ function _onFocusOut() {
|
|
|
69
59
|
*/
|
|
70
60
|
export function mount(ctx: LeftPanelCtx) {
|
|
71
61
|
_ctx = ctx;
|
|
72
|
-
leftPanel
|
|
73
|
-
|
|
62
|
+
_scheduler = createPanelScheduler({ root: leftPanel, render: _doRender });
|
|
63
|
+
_scheduler.bindFocus();
|
|
74
64
|
_scope = effectScope();
|
|
75
65
|
_scope.run(() => {
|
|
76
66
|
effect(() => {
|
|
@@ -85,9 +75,7 @@ export function mount(ctx: LeftPanelCtx) {
|
|
|
85
75
|
void tab.session.ui.gitLoading;
|
|
86
76
|
void tab.session.ui.gitError;
|
|
87
77
|
}
|
|
88
|
-
|
|
89
|
-
render();
|
|
90
|
-
}
|
|
78
|
+
render();
|
|
91
79
|
});
|
|
92
80
|
});
|
|
93
81
|
}
|
|
@@ -96,25 +84,21 @@ export function unmount() {
|
|
|
96
84
|
_scope?.stop();
|
|
97
85
|
_scope = null;
|
|
98
86
|
_ctx = null;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
_hasFocus = false;
|
|
87
|
+
_scheduler?.unbind();
|
|
88
|
+
_scheduler = null;
|
|
102
89
|
}
|
|
103
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Request a render. Coalesced and deferred while a text input in the panel is focused (so explicit
|
|
93
|
+
* callers — renderOnly("leftPanel"), tab switches, etc. — can never clobber a field mid-edit).
|
|
94
|
+
*/
|
|
104
95
|
export function render() {
|
|
105
|
-
|
|
106
|
-
if (_rendering) return;
|
|
107
|
-
if (!_scheduled) {
|
|
108
|
-
_scheduled = true;
|
|
109
|
-
queueMicrotask(_flush);
|
|
110
|
-
}
|
|
96
|
+
_scheduler?.schedule();
|
|
111
97
|
}
|
|
112
98
|
|
|
113
|
-
|
|
114
|
-
|
|
99
|
+
/** Actual DOM paint, invoked by the scheduler. Includes a Lit-marker-corruption recovery retry. */
|
|
100
|
+
function _doRender() {
|
|
115
101
|
if (!_ctx) return;
|
|
116
|
-
if (_rendering) return;
|
|
117
|
-
_rendering = true;
|
|
118
102
|
try {
|
|
119
103
|
_render();
|
|
120
104
|
} catch (e) {
|
|
@@ -127,8 +111,6 @@ function _flush() {
|
|
|
127
111
|
} catch (e2) {
|
|
128
112
|
console.error("left-panel retry failed:", e2);
|
|
129
113
|
}
|
|
130
|
-
} finally {
|
|
131
|
-
_rendering = false;
|
|
132
114
|
}
|
|
133
115
|
|
|
134
116
|
if (view.leftTab === "layers") {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Panel-scheduler.ts — Shared focus-aware render scheduler for Studio panels.
|
|
4
|
+
*
|
|
5
|
+
* Generalizes the guard that previously lived only in the right panel: render requests are
|
|
6
|
+
* coalesced via requestAnimationFrame and DEFERRED while a text input inside the panel root is
|
|
7
|
+
* focused, then flushed on focusout. Routing every render path (reactive effect, renderOnly(...),
|
|
8
|
+
* explicit calls) through one scheduler means a focused field is never rebuilt mid-edit — which is
|
|
9
|
+
* what previously truncated or dropped characters in the document/head sidebar.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Is the element (or its shadow-DOM active descendant) a text-entry control whose value would be
|
|
14
|
+
* clobbered by a re-render? Covers native inputs and the Spectrum web components used in panels.
|
|
15
|
+
*
|
|
16
|
+
* @param {Element | null} el
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function isTextInput(el: Element | null): boolean {
|
|
20
|
+
if (!el) return false;
|
|
21
|
+
const tag = el.tagName.toLowerCase();
|
|
22
|
+
if (tag === "input" || tag === "textarea") return true;
|
|
23
|
+
if (tag === "sp-textfield" || tag === "sp-number-field" || tag === "sp-search") return true;
|
|
24
|
+
if (el.shadowRoot?.activeElement) return isTextInput(el.shadowRoot.activeElement);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PanelScheduler {
|
|
29
|
+
/** Request a render. Coalesced; deferred while a text input in the panel is focused. */
|
|
30
|
+
schedule(): void;
|
|
31
|
+
/** True when a render would currently be deferred (text input focused or blockWhile()). */
|
|
32
|
+
isEditing(): boolean;
|
|
33
|
+
/** Attach focusin/focusout listeners to the panel root. */
|
|
34
|
+
bindFocus(): void;
|
|
35
|
+
/** Detach listeners and cancel any pending frame. */
|
|
36
|
+
unbind(): void;
|
|
37
|
+
/** Render immediately, bypassing the rAF coalescing (still respects the focus guard). */
|
|
38
|
+
flushNow(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a focus-aware render scheduler bound to a panel root.
|
|
43
|
+
*
|
|
44
|
+
* @param {{
|
|
45
|
+
* root: HTMLElement;
|
|
46
|
+
* render: () => void;
|
|
47
|
+
* blockWhile?: () => boolean;
|
|
48
|
+
* }} opts
|
|
49
|
+
* - `render` does the actual DOM paint; `blockWhile` is an extra defer predicate (e.g. a color
|
|
50
|
+
* popover being open).
|
|
51
|
+
* @returns {PanelScheduler}
|
|
52
|
+
*/
|
|
53
|
+
export function createPanelScheduler(opts: {
|
|
54
|
+
root: HTMLElement;
|
|
55
|
+
render: () => void;
|
|
56
|
+
blockWhile?: () => boolean;
|
|
57
|
+
}): PanelScheduler {
|
|
58
|
+
const { root, render, blockWhile } = opts;
|
|
59
|
+
let editing = false;
|
|
60
|
+
let scheduled = false;
|
|
61
|
+
let pending = false;
|
|
62
|
+
let rendering = false;
|
|
63
|
+
let rafId = 0;
|
|
64
|
+
|
|
65
|
+
const blocked = () => editing || (blockWhile ? blockWhile() : false);
|
|
66
|
+
|
|
67
|
+
function flush() {
|
|
68
|
+
scheduled = false;
|
|
69
|
+
rafId = 0;
|
|
70
|
+
if (rendering) return;
|
|
71
|
+
// Defer while a field is focused — flushed later by focusout (or the next schedule()).
|
|
72
|
+
if (blocked()) {
|
|
73
|
+
pending = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
pending = false;
|
|
77
|
+
rendering = true;
|
|
78
|
+
try {
|
|
79
|
+
render();
|
|
80
|
+
} finally {
|
|
81
|
+
rendering = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function schedule() {
|
|
86
|
+
if (scheduled) return;
|
|
87
|
+
scheduled = true;
|
|
88
|
+
rafId = requestAnimationFrame(flush);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onFocusIn(e: FocusEvent) {
|
|
92
|
+
editing = isTextInput(e.target as Element);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onFocusOut() {
|
|
96
|
+
// Clear the editing flag and schedule a flush. If focus is merely moving to another field in
|
|
97
|
+
// the same panel, the synchronous focusin that follows re-sets `editing` before the rAF runs,
|
|
98
|
+
// so the deferred render keeps waiting (no mid-edit clobber).
|
|
99
|
+
editing = false;
|
|
100
|
+
if (pending) schedule();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
schedule,
|
|
105
|
+
isEditing: () => blocked(),
|
|
106
|
+
bindFocus() {
|
|
107
|
+
root.addEventListener("focusin", onFocusIn);
|
|
108
|
+
root.addEventListener("focusout", onFocusOut);
|
|
109
|
+
},
|
|
110
|
+
unbind() {
|
|
111
|
+
root.removeEventListener("focusin", onFocusIn);
|
|
112
|
+
root.removeEventListener("focusout", onFocusOut);
|
|
113
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
114
|
+
scheduled = false;
|
|
115
|
+
pending = false;
|
|
116
|
+
editing = false;
|
|
117
|
+
rendering = false;
|
|
118
|
+
},
|
|
119
|
+
flushNow() {
|
|
120
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
121
|
+
rafId = 0;
|
|
122
|
+
scheduled = false;
|
|
123
|
+
flush();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -20,6 +20,7 @@ import { view } from "../view";
|
|
|
20
20
|
import { componentRegistry } from "../files/components";
|
|
21
21
|
import { widgetForType } from "./style-inputs";
|
|
22
22
|
import { renderFieldRow } from "../ui/field-row";
|
|
23
|
+
import { spTextField, spTextArea } from "../ui/field-input";
|
|
23
24
|
import {
|
|
24
25
|
attrLabel,
|
|
25
26
|
inferInputType,
|
|
@@ -96,28 +97,17 @@ function bindableFieldRow(
|
|
|
96
97
|
(d as JxPrototypeDef)?.$prototype !== "Function",
|
|
97
98
|
);
|
|
98
99
|
|
|
99
|
-
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
100
|
-
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
-
const onInput = (e: Event) => {
|
|
102
|
-
clearTimeout(debounce);
|
|
103
|
-
debounce = setTimeout(() => onChange((e.target as HTMLInputElement).value), 400);
|
|
104
|
-
};
|
|
105
|
-
|
|
106
100
|
const staticVal = isBound ? "" : (rawValue ?? "");
|
|
101
|
+
const fieldKey = `prop:${label}`;
|
|
107
102
|
const staticTpl =
|
|
108
103
|
type === "textarea"
|
|
109
|
-
?
|
|
110
|
-
multiline
|
|
111
|
-
size="s"
|
|
112
|
-
.value=${live(staticVal)}
|
|
113
|
-
@input=${onInput}
|
|
114
|
-
></sp-textfield>`
|
|
104
|
+
? spTextArea(fieldKey, String(staticVal), (v: string) => onChange(v))
|
|
115
105
|
: type === "checkbox"
|
|
116
106
|
? html`<sp-checkbox
|
|
117
107
|
?checked=${!!staticVal}
|
|
118
108
|
@change=${(e: Event) => onChange((e.target as HTMLInputElement).checked)}
|
|
119
109
|
></sp-checkbox>`
|
|
120
|
-
:
|
|
110
|
+
: spTextField(fieldKey, String(staticVal), (v: string) => onChange(v));
|
|
121
111
|
|
|
122
112
|
const boundTpl = html`
|
|
123
113
|
<sp-picker
|
|
@@ -476,15 +466,14 @@ function renderComponentPropsFieldsTemplate(
|
|
|
476
466
|
} else if (prop.format === "color") {
|
|
477
467
|
widgetTpl = renderColorSelector(prop.name, staticVal, onChange);
|
|
478
468
|
} else if (prop.format === "date") {
|
|
479
|
-
widgetTpl =
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
></sp-textfield>`;
|
|
469
|
+
widgetTpl = spTextField(
|
|
470
|
+
`cprop:${prop.name}`,
|
|
471
|
+
String(staticVal),
|
|
472
|
+
(v: string) => onChange(v),
|
|
473
|
+
{
|
|
474
|
+
placeholder: "YYYY-MM-DD",
|
|
475
|
+
},
|
|
476
|
+
);
|
|
488
477
|
} else if (parsed.kind === "boolean") {
|
|
489
478
|
widgetTpl = html`<sp-checkbox
|
|
490
479
|
size="s"
|
|
@@ -511,14 +500,9 @@ function renderComponentPropsFieldsTemplate(
|
|
|
511
500
|
onChange(e.detail?.value ?? (e.target as HTMLInputElement).value)}
|
|
512
501
|
></jx-value-selector>`;
|
|
513
502
|
} else {
|
|
514
|
-
widgetTpl =
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
@input=${(e: Event) => {
|
|
518
|
-
clearTimeout(debounce);
|
|
519
|
-
debounce = setTimeout(() => onChange((e.target as HTMLInputElement).value), 400);
|
|
520
|
-
}}
|
|
521
|
-
></sp-textfield>`;
|
|
503
|
+
widgetTpl = spTextField(`cprop:${prop.name}`, String(staticVal), (v: string) =>
|
|
504
|
+
onChange(v),
|
|
505
|
+
);
|
|
522
506
|
}
|
|
523
507
|
|
|
524
508
|
return html`
|
|
@@ -1085,20 +1069,11 @@ export function renderPropertiesPanelTemplate(ctx: {
|
|
|
1085
1069
|
: nothing}
|
|
1086
1070
|
<sp-field-label size="s">ID</sp-field-label>
|
|
1087
1071
|
</div>
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
mutateUpdateProperty(
|
|
1094
|
-
t,
|
|
1095
|
-
path,
|
|
1096
|
-
"$id",
|
|
1097
|
-
(e.target as HTMLInputElement).value || undefined,
|
|
1098
|
-
),
|
|
1099
|
-
);
|
|
1100
|
-
})}
|
|
1101
|
-
></sp-textfield>
|
|
1072
|
+
${spTextField("prop:$id", String(node.$id || ""), (v: string) =>
|
|
1073
|
+
transactDoc(activeTab.value, (t) =>
|
|
1074
|
+
mutateUpdateProperty(t, path, "$id", v || undefined),
|
|
1075
|
+
),
|
|
1076
|
+
)}
|
|
1102
1077
|
</div>
|
|
1103
1078
|
<div class="style-row" data-prop="className">
|
|
1104
1079
|
<div class="style-row-label">
|
|
@@ -1116,20 +1091,11 @@ export function renderPropertiesPanelTemplate(ctx: {
|
|
|
1116
1091
|
: nothing}
|
|
1117
1092
|
<sp-field-label size="s">Class</sp-field-label>
|
|
1118
1093
|
</div>
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
mutateUpdateProperty(
|
|
1125
|
-
t,
|
|
1126
|
-
path,
|
|
1127
|
-
"className",
|
|
1128
|
-
(e.target as HTMLInputElement).value || undefined,
|
|
1129
|
-
),
|
|
1130
|
-
);
|
|
1131
|
-
})}
|
|
1132
|
-
></sp-textfield>
|
|
1094
|
+
${spTextField("prop:className", String(node.className || ""), (v: string) =>
|
|
1095
|
+
transactDoc(activeTab.value, (t) =>
|
|
1096
|
+
mutateUpdateProperty(t, path, "className", v || undefined),
|
|
1097
|
+
),
|
|
1098
|
+
)}
|
|
1133
1099
|
</div>
|
|
1134
1100
|
${!Array.isArray(node.children) || node.children.length === 0
|
|
1135
1101
|
? html`
|
|
@@ -1149,25 +1115,14 @@ export function renderPropertiesPanelTemplate(ctx: {
|
|
|
1149
1115
|
: nothing}
|
|
1150
1116
|
<sp-field-label size="s">Text Content</sp-field-label>
|
|
1151
1117
|
</div>
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
typeof node.textContent === "string"
|
|
1157
|
-
? node.textContent
|
|
1158
|
-
: (node.textContent ?? ""),
|
|
1159
|
-
)}
|
|
1160
|
-
@input=${debouncedStyleCommit("prop:textContent", 400, (e: Event) => {
|
|
1118
|
+
${spTextArea(
|
|
1119
|
+
"prop:textContent",
|
|
1120
|
+
typeof node.textContent === "string" ? node.textContent : "",
|
|
1121
|
+
(v: string) =>
|
|
1161
1122
|
transactDoc(activeTab.value, (t) =>
|
|
1162
|
-
mutateUpdateProperty(
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
"textContent",
|
|
1166
|
-
(e.target as HTMLInputElement).value || undefined,
|
|
1167
|
-
),
|
|
1168
|
-
);
|
|
1169
|
-
})}
|
|
1170
|
-
></sp-textfield>
|
|
1123
|
+
mutateUpdateProperty(t, path, "textContent", v || undefined),
|
|
1124
|
+
),
|
|
1125
|
+
)}
|
|
1171
1126
|
</div>
|
|
1172
1127
|
`
|
|
1173
1128
|
: nothing}
|