@jxsuite/studio 0.7.0 → 0.9.0

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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Canvas DnD — extracted from studio.js (Phase 4m). Registers canvas elements as drag-and-drop
3
+ * targets using @atlaskit/pragmatic-drag-and-drop.
4
+ */
5
+
6
+ import {
7
+ dropTargetForElements,
8
+ monitorForElements,
9
+ } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
10
+
11
+ import {
12
+ getState,
13
+ elToPath,
14
+ canvasPanels,
15
+ getNodeAtPath,
16
+ VOID_ELEMENTS,
17
+ isAncestor,
18
+ } from "../store.js";
19
+ import { view } from "../view.js";
20
+ import { applyDropInstruction } from "../panels/dnd.js";
21
+
22
+ /** @type {any} */
23
+ let _ctx = null;
24
+
25
+ /**
26
+ * Initialize the canvas DnD module.
27
+ *
28
+ * @param {{ effectiveZoom: () => number }} ctx
29
+ */
30
+ export function initCanvasDnD(ctx) {
31
+ _ctx = ctx;
32
+ }
33
+
34
+ /**
35
+ * Register all canvas elements in a panel as DnD drop targets.
36
+ *
37
+ * @param {any} panel
38
+ */
39
+ export function registerPanelDnD(panel) {
40
+ const { canvas, dropLine } = panel;
41
+ const allEls = canvas.querySelectorAll("*");
42
+
43
+ const monitorCleanup = monitorForElements({
44
+ onDragStart() {
45
+ for (const el of canvas.querySelectorAll("*")) {
46
+ /** @type {any} */ (el).style.pointerEvents = "auto";
47
+ }
48
+ for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "none";
49
+ },
50
+ onDrag({ location }) {
51
+ view.lastDragInput = location.current.input;
52
+ },
53
+ onDrop() {
54
+ for (const p of canvasPanels) p.dropLine.style.display = "none";
55
+ view.lastDragInput = null;
56
+ for (const el of canvas.querySelectorAll("*")) {
57
+ /** @type {any} */ (el).style.pointerEvents = "none";
58
+ }
59
+ for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "";
60
+ },
61
+ });
62
+ view.canvasDndCleanups.push(monitorCleanup);
63
+
64
+ const S = getState();
65
+ for (const el of allEls) {
66
+ const elPath = elToPath.get(el);
67
+ if (!elPath) continue;
68
+
69
+ const node = getNodeAtPath(S.document, elPath);
70
+ const isVoid = VOID_ELEMENTS.has((node?.tagName || "div").toLowerCase());
71
+
72
+ const cleanup = dropTargetForElements({
73
+ element: el,
74
+ canDrop({ source }) {
75
+ const srcPath = source.data.path;
76
+ if (srcPath && isAncestor(/** @type {any} */ (srcPath), elPath)) return false;
77
+ return true;
78
+ },
79
+ getData() {
80
+ return { path: elPath, _isVoid: isVoid };
81
+ },
82
+ onDragEnter() {
83
+ showCanvasDropIndicator(el, elPath, isVoid, panel);
84
+ },
85
+ onDrag() {
86
+ showCanvasDropIndicator(el, elPath, isVoid, panel);
87
+ },
88
+ onDragLeave() {
89
+ dropLine.style.display = "none";
90
+ el.classList.remove("canvas-drop-target");
91
+ },
92
+ onDrop({ source }) {
93
+ dropLine.style.display = "none";
94
+ el.classList.remove("canvas-drop-target");
95
+ const instruction = getCanvasDropInstruction(el, elPath, isVoid);
96
+ if (!instruction) return;
97
+ applyDropInstruction(instruction, source.data, elPath);
98
+ },
99
+ });
100
+ view.canvasDndCleanups.push(cleanup);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @param {any} el
106
+ * @param {any} elPath
107
+ * @param {any} isVoid
108
+ */
109
+ function getCanvasDropInstruction(el, elPath, isVoid) {
110
+ const rect = el.getBoundingClientRect();
111
+ if (!view.lastDragInput) return null;
112
+ const y = view.lastDragInput.clientY;
113
+ const relY = (y - rect.top) / rect.height;
114
+
115
+ if (elPath.length === 0) return { type: "make-child" };
116
+ if (isVoid) return relY < 0.5 ? { type: "reorder-above" } : { type: "reorder-below" };
117
+ if (relY < 0.25) return { type: "reorder-above" };
118
+ if (relY > 0.75) return { type: "reorder-below" };
119
+ return { type: "make-child" };
120
+ }
121
+
122
+ /**
123
+ * @param {any} el
124
+ * @param {any} elPath
125
+ * @param {any} isVoid
126
+ * @param {any} panel
127
+ */
128
+ function showCanvasDropIndicator(el, elPath, isVoid, panel) {
129
+ const instruction = getCanvasDropInstruction(el, elPath, isVoid);
130
+ const { dropLine, viewport } = panel;
131
+ if (!instruction) {
132
+ dropLine.style.display = "none";
133
+ return;
134
+ }
135
+
136
+ const scale = _ctx.effectiveZoom();
137
+ const wrapRect = viewport.getBoundingClientRect();
138
+ const elRect = el.getBoundingClientRect();
139
+ const left = (elRect.left - wrapRect.left + viewport.scrollLeft) / scale;
140
+ const width = elRect.width / scale;
141
+
142
+ if (instruction.type === "make-child") {
143
+ dropLine.style.display = "block";
144
+ dropLine.style.top = `${(elRect.top - wrapRect.top + viewport.scrollTop) / scale}px`;
145
+ dropLine.style.left = `${left}px`;
146
+ dropLine.style.width = `${width}px`;
147
+ dropLine.style.height = `${elRect.height / scale}px`;
148
+ dropLine.className = "canvas-drop-indicator inside";
149
+ el.classList.add("canvas-drop-target");
150
+ return;
151
+ }
152
+
153
+ el.classList.remove("canvas-drop-target");
154
+ const top =
155
+ instruction.type === "reorder-above"
156
+ ? (elRect.top - wrapRect.top + viewport.scrollTop) / scale
157
+ : (elRect.bottom - wrapRect.top + viewport.scrollTop) / scale;
158
+
159
+ dropLine.style.display = "block";
160
+ dropLine.style.top = `${top}px`;
161
+ dropLine.style.left = `${left}px`;
162
+ dropLine.style.width = `${width}px`;
163
+ dropLine.style.height = "2px";
164
+ dropLine.className = "canvas-drop-indicator line";
165
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * DnD registration functions — extracted from studio.js (Phase 4). Registers drag-and-drop behavior
3
+ * on layer rows, component cards, and element cards.
4
+ */
5
+
6
+ import {
7
+ draggable,
8
+ dropTargetForElements,
9
+ monitorForElements,
10
+ } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
11
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
12
+ import {
13
+ attachInstruction,
14
+ extractInstruction,
15
+ } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
16
+
17
+ import {
18
+ getState,
19
+ update,
20
+ leftPanel,
21
+ moveNode,
22
+ insertNode,
23
+ applyMutation,
24
+ getNodeAtPath,
25
+ parentElementPath,
26
+ childIndex,
27
+ isAncestor,
28
+ } from "../store.js";
29
+ import { view } from "../view.js";
30
+ import { componentRegistry, computeRelativePath } from "../files/components.js";
31
+ import { renderComponentPreview } from "./stylebook-panel.js";
32
+ import { defaultDef, unsafeTags } from "./shared.js";
33
+
34
+ /** Register DnD on layer rows — called from left-panel.js after render */
35
+ export function registerLayersDnD() {
36
+ requestAnimationFrame(() => {
37
+ const container = /** @type {any} */ (leftPanel)?.querySelector(".layers-container");
38
+ if (!container) return;
39
+
40
+ container.querySelectorAll("[data-dnd-row]").forEach(
41
+ /** @param {any} row */ (row) => {
42
+ const rowPath = /** @type {string} */ (row.dataset.path)
43
+ .split("/")
44
+ .map((/** @type {any} */ s) => (/^\d+$/.test(s) ? parseInt(s) : s));
45
+ const rowDepth = parseInt(/** @type {string} */ (row.dataset.dndDepth)) || 0;
46
+ const isVoid = row.hasAttribute("data-dnd-void");
47
+
48
+ const cleanup = combine(
49
+ draggable({
50
+ element: row,
51
+ canDrag(/** @type {any} */ { element: _el, input }) {
52
+ const target = /** @type {HTMLElement} */ (
53
+ document.elementFromPoint(input.clientX, input.clientY)
54
+ );
55
+ if (target?.closest(".layer-actions")) return false;
56
+ return true;
57
+ },
58
+ getInitialData() {
59
+ return { type: "tree-node", path: rowPath };
60
+ },
61
+ onDragStart() {
62
+ row.classList.add("dragging");
63
+ view.layerDragSourceHeight = row.offsetHeight;
64
+ },
65
+ onDrop() {
66
+ row.classList.remove("dragging");
67
+ },
68
+ }),
69
+ dropTargetForElements({
70
+ element: row,
71
+ canDrop(/** @type {any} */ { source }) {
72
+ const srcPath = source.data.path;
73
+ if (srcPath && isAncestor(srcPath, rowPath)) return false;
74
+ return true;
75
+ },
76
+ getData(/** @type {any} */ { input, element }) {
77
+ return attachInstruction(
78
+ { path: rowPath },
79
+ /** @type {any} */ ({
80
+ input,
81
+ element,
82
+ currentLevel: rowDepth,
83
+ indentPerLevel: 16,
84
+ block: isVoid ? ["make-child"] : [],
85
+ }),
86
+ );
87
+ },
88
+ onDragEnter(/** @type {any} */ { self }) {
89
+ showLayerDropGap(row, self.data, container);
90
+ },
91
+ onDrag(/** @type {any} */ { self }) {
92
+ showLayerDropGap(row, self.data, container);
93
+ },
94
+ onDragLeave() {
95
+ clearLayerDropGap(container);
96
+ },
97
+ onDrop() {
98
+ clearLayerDropGap(container);
99
+ },
100
+ }),
101
+ );
102
+ view.dndCleanups.push(cleanup);
103
+ },
104
+ );
105
+
106
+ // Global monitor
107
+ const monitorCleanup = monitorForElements({
108
+ onDrop(/** @type {any} */ { source, location }) {
109
+ clearLayerDropGap(container);
110
+ const target = location.current.dropTargets[0];
111
+ if (!target) return;
112
+ const instruction = extractInstruction(target.data);
113
+ if (!instruction || instruction.type === "instruction-blocked") return;
114
+ const srcData = source.data;
115
+ const targetPath = target.data.path;
116
+ applyDropInstruction(instruction, srcData, targetPath);
117
+ },
118
+ });
119
+ view.dndCleanups.push(monitorCleanup);
120
+ });
121
+ }
122
+
123
+ /** Register DnD on component rows — called from renderLeftPanel when tab=components */
124
+ export function registerComponentsDnD() {
125
+ requestAnimationFrame(() => {
126
+ const container = /** @type {any} */ (leftPanel)?.querySelector(".components-section");
127
+ if (!container) return;
128
+
129
+ container.querySelectorAll("[data-component-tag]").forEach(
130
+ /** @param {any} row */ (row) => {
131
+ const tagName = row.dataset.componentTag;
132
+ if (!tagName) return;
133
+ const comp = componentRegistry.find(/** @param {any} c */ (c) => c.tagName === tagName);
134
+ if (!comp) return;
135
+
136
+ // Fill preview with live rendered component
137
+ const preview = row.querySelector(".element-card-preview");
138
+ if (preview && !preview.querySelector(tagName)) {
139
+ renderComponentPreview(comp).then((/** @type {any} */ el) => {
140
+ preview.textContent = "";
141
+ preview.appendChild(el);
142
+ });
143
+ }
144
+
145
+ const instanceDef = {
146
+ tagName: comp.tagName,
147
+ $props: Object.fromEntries(
148
+ comp.props.map((/** @type {any} */ p) => [
149
+ p.name,
150
+ p.default !== undefined ? p.default : "",
151
+ ]),
152
+ ),
153
+ };
154
+ const cleanup = draggable({
155
+ element: row,
156
+ getInitialData() {
157
+ return { type: "block", fragment: structuredClone(instanceDef) };
158
+ },
159
+ });
160
+ view.dndCleanups.push(cleanup);
161
+ },
162
+ );
163
+ });
164
+ }
165
+
166
+ /** Register DnD on element (HTML block) rows */
167
+ export function registerElementsDnD() {
168
+ requestAnimationFrame(() => {
169
+ const container = /** @type {any} */ (leftPanel)?.querySelector(".panel-body");
170
+ if (!container) return;
171
+ container.querySelectorAll("[data-block-tag]").forEach(
172
+ /** @param {any} row */ (row) => {
173
+ const tag = row.dataset.blockTag;
174
+ const preview = row.querySelector(".element-card-preview");
175
+ if (preview && !preview.firstChild) {
176
+ const el = document.createElement(unsafeTags.has(tag) ? "span" : tag);
177
+ el.textContent = tag;
178
+ preview.appendChild(el);
179
+ }
180
+ const def = defaultDef(tag);
181
+ const cleanup = draggable({
182
+ element: row,
183
+ getInitialData() {
184
+ return { type: "block", fragment: structuredClone(def) };
185
+ },
186
+ });
187
+ view.dndCleanups.push(cleanup);
188
+ },
189
+ );
190
+ });
191
+ }
192
+
193
+ /**
194
+ * @param {any} rowEl
195
+ * @param {any} data
196
+ * @param {any} container
197
+ */
198
+ export function showLayerDropGap(rowEl, data, container) {
199
+ const instruction = extractInstruction(data);
200
+
201
+ // Clear previous drop-target highlight
202
+ if (view._currentDropTargetRow && view._currentDropTargetRow !== rowEl) {
203
+ view._currentDropTargetRow.classList.remove("drop-target");
204
+ }
205
+
206
+ if (!instruction || instruction.type === "instruction-blocked") {
207
+ clearLayerDropGap(container);
208
+ return;
209
+ }
210
+
211
+ if (instruction.type === "make-child") {
212
+ clearLayerDropGap(container);
213
+ rowEl.classList.add("drop-target");
214
+ view._currentDropTargetRow = rowEl;
215
+ return;
216
+ }
217
+
218
+ rowEl.classList.remove("drop-target");
219
+ view._currentDropTargetRow = rowEl;
220
+
221
+ // Shift rows to create gap
222
+ const rows = Array.from(container.querySelectorAll(".layers-tree .layer-row"));
223
+ const targetIdx = rows.indexOf(rowEl);
224
+ const gap = view.layerDragSourceHeight;
225
+
226
+ for (let i = 0; i < rows.length; i++) {
227
+ if (/** @type {any} */ (rows[i]).classList.contains("dragging")) continue;
228
+ if (instruction.type === "reorder-above") {
229
+ /** @type {any} */ (rows[i]).style.transform = i >= targetIdx ? `translateY(${gap}px)` : "";
230
+ } else {
231
+ /** @type {any} */ (rows[i]).style.transform = i > targetIdx ? `translateY(${gap}px)` : "";
232
+ }
233
+ }
234
+ }
235
+
236
+ /** @param {any} container */
237
+ export function clearLayerDropGap(container) {
238
+ if (view._currentDropTargetRow) {
239
+ view._currentDropTargetRow.classList.remove("drop-target");
240
+ view._currentDropTargetRow = null;
241
+ }
242
+ const rows = container.querySelectorAll(".layers-tree .layer-row");
243
+ for (const r of rows) /** @type {any} */ (r).style.transform = "";
244
+ }
245
+
246
+ /**
247
+ * Apply a DnD instruction to the state
248
+ *
249
+ * @param {any} instruction
250
+ * @param {any} srcData
251
+ * @param {any} targetPath
252
+ */
253
+ export function applyDropInstruction(instruction, srcData, targetPath) {
254
+ const S = getState();
255
+ if (srcData.type === "tree-node") {
256
+ const fromPath = srcData.path;
257
+ const targetParent = /** @type {any} */ (parentElementPath(targetPath));
258
+ const targetIdx = /** @type {number} */ (childIndex(targetPath));
259
+
260
+ switch (instruction.type) {
261
+ case "reorder-above":
262
+ update(moveNode(S, fromPath, targetParent, targetIdx));
263
+ break;
264
+ case "reorder-below":
265
+ update(moveNode(S, fromPath, targetParent, targetIdx + 1));
266
+ break;
267
+ case "make-child": {
268
+ const target = getNodeAtPath(S.document, targetPath);
269
+ const len = target?.children?.length || 0;
270
+ update(moveNode(S, fromPath, targetPath, len));
271
+ break;
272
+ }
273
+ }
274
+ } else if (srcData.type === "block") {
275
+ const targetParent = /** @type {any} */ (parentElementPath(targetPath));
276
+ const targetIdx = /** @type {number} */ (childIndex(targetPath));
277
+
278
+ switch (instruction.type) {
279
+ case "reorder-above":
280
+ update(insertNode(S, targetParent, targetIdx, structuredClone(srcData.fragment)));
281
+ break;
282
+ case "reorder-below":
283
+ update(insertNode(S, targetParent, targetIdx + 1, structuredClone(srcData.fragment)));
284
+ break;
285
+ case "make-child": {
286
+ const target = getNodeAtPath(S.document, targetPath);
287
+ const len = target?.children?.length || 0;
288
+ update(insertNode(S, targetPath, len, structuredClone(srcData.fragment)));
289
+ break;
290
+ }
291
+ }
292
+
293
+ // Auto-import to $elements if the dropped block is a custom component
294
+ const tag = srcData.fragment?.tagName;
295
+ if (tag && tag.includes("-")) {
296
+ const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === tag);
297
+ if (comp) {
298
+ const currentS = getState();
299
+ const elements = currentS.document.$elements || [];
300
+ if (comp.source === "npm") {
301
+ const specifier = comp.modulePath ? `${comp.package}/${comp.modulePath}` : comp.package;
302
+ const alreadyImported = elements.some(
303
+ (/** @type {any} */ e) => e === specifier || e === comp.package,
304
+ );
305
+ if (!alreadyImported) {
306
+ update(
307
+ applyMutation(currentS, (/** @type {any} */ doc) => {
308
+ if (!doc.$elements) doc.$elements = [];
309
+ doc.$elements.push(specifier);
310
+ }),
311
+ );
312
+ }
313
+ } else {
314
+ const alreadyImported = elements.some(
315
+ (/** @type {any} */ e) =>
316
+ e.$ref &&
317
+ (e.$ref === `./${comp.path}` || e.$ref.endsWith(comp.path.split("/").pop())),
318
+ );
319
+ if (!alreadyImported) {
320
+ const relPath = computeRelativePath(currentS.documentPath, comp.path);
321
+ update(
322
+ applyMutation(currentS, (/** @type {any} */ doc) => {
323
+ if (!doc.$elements) doc.$elements = [];
324
+ doc.$elements.push({ $ref: relPath });
325
+ }),
326
+ );
327
+ }
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Editor panels — extracted from studio.js (Phase 4g). Monaco-based function editor (JS mode) and
3
+ * completion provider for state scope variables.
4
+ */
5
+
6
+ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
7
+ import { html, render as litRender, nothing } from "lit-html";
8
+ import { ref } from "lit-html/directives/ref.js";
9
+
10
+ import {
11
+ getState,
12
+ update,
13
+ renderOnly,
14
+ canvasWrap,
15
+ canvasPanels,
16
+ updateDef,
17
+ updateProperty,
18
+ getNodeAtPath,
19
+ } from "../store.js";
20
+ import { view } from "../view.js";
21
+ import { codeService, setLintMarkers, getFunctionArgs } from "../services/code-services.js";
22
+
23
+ function getFunctionBody(/** @type {any} */ editing) {
24
+ const S = getState();
25
+ if (editing.type === "def") {
26
+ return S.document.state?.[editing.defName]?.body || "";
27
+ } else if (editing.type === "event") {
28
+ const node = getNodeAtPath(S.document, editing.path);
29
+ return node?.[editing.eventKey]?.body || "";
30
+ }
31
+ return "";
32
+ }
33
+
34
+ export function renderFunctionEditor() {
35
+ const S = getState();
36
+ const editing = S.ui.editingFunction;
37
+
38
+ // If editor already exists and matches current target, just sync value
39
+ if (view.functionEditor && view.functionEditor._editingTarget === JSON.stringify(editing)) {
40
+ const body = getFunctionBody(editing);
41
+ const currentVal = view.functionEditor.getValue();
42
+ if (currentVal !== body) {
43
+ view.functionEditor._ignoreNextChange = true;
44
+ view.functionEditor.setValue(body);
45
+ }
46
+ return;
47
+ }
48
+
49
+ // Dispose previous editors
50
+ if (view.functionEditor) {
51
+ view.functionEditor.dispose();
52
+ view.functionEditor = null;
53
+ }
54
+ if (view.monacoEditor) {
55
+ view.monacoEditor.dispose();
56
+ view.monacoEditor = null;
57
+ }
58
+
59
+ // Clean up canvas DnD and event handlers
60
+ for (const fn of view.canvasDndCleanups) fn();
61
+ view.canvasDndCleanups = [];
62
+ for (const fn of view.canvasEventCleanups) fn();
63
+ view.canvasEventCleanups = [];
64
+ canvasPanels.length = 0;
65
+
66
+ litRender(nothing, canvasWrap);
67
+ canvasWrap.style.padding = "0";
68
+
69
+ // Toolbar breadcrumb handles context display — re-render it
70
+ renderOnly("toolbar");
71
+
72
+ // Editor container
73
+ /** @type {HTMLDivElement | null} */
74
+ let editorContainer = null;
75
+ litRender(
76
+ html`<div
77
+ class="source-editor"
78
+ ${ref((el) => {
79
+ if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
80
+ })}
81
+ ></div>`,
82
+ canvasWrap,
83
+ );
84
+
85
+ const body = getFunctionBody(editing);
86
+ const args = getFunctionArgs(editing, S);
87
+
88
+ view.functionEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
89
+ value: body,
90
+ language: "javascript",
91
+ theme: "vs-dark",
92
+ automaticLayout: true,
93
+ minimap: { enabled: false },
94
+ fontSize: 12,
95
+ fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
96
+ lineNumbers: "on",
97
+ scrollBeyondLastLine: false,
98
+ wordWrap: "on",
99
+ tabSize: 2,
100
+ });
101
+ view.functionEditor._editingTarget = JSON.stringify(editing);
102
+
103
+ // Format on open — show pretty-printed code, then run initial lint
104
+ codeService("format", { code: body, args }).then((result) => {
105
+ if (result?.code != null && view.functionEditor) {
106
+ view.functionEditor._ignoreNextChange = true;
107
+ view.functionEditor.setValue(result.code);
108
+ }
109
+ });
110
+ codeService("lint", { code: body, args }).then((result) => {
111
+ if (result?.diagnostics && view.functionEditor)
112
+ setLintMarkers(view.functionEditor, result.diagnostics);
113
+ });
114
+
115
+ // Debounced sync back to state + lint on edit
116
+ /** @type {any} */
117
+ let syncDebounce;
118
+ /** @type {any} */
119
+ let lintDebounce;
120
+ let lintGen = 0;
121
+ view.functionEditor.onDidChangeModelContent(() => {
122
+ if (view.functionEditor._ignoreNextChange) {
123
+ view.functionEditor._ignoreNextChange = false;
124
+ return;
125
+ }
126
+
127
+ clearTimeout(syncDebounce);
128
+ syncDebounce = setTimeout(() => {
129
+ const S = getState();
130
+ const newBody = view.functionEditor.getValue();
131
+ if (editing.type === "def") {
132
+ update(updateDef(S, editing.defName, { body: newBody }));
133
+ } else if (editing.type === "event") {
134
+ const node = getNodeAtPath(S.document, editing.path);
135
+ const current = node?.[editing.eventKey] || {};
136
+ update(
137
+ updateProperty(S, editing.path, editing.eventKey, {
138
+ ...current,
139
+ $prototype: "Function",
140
+ body: newBody,
141
+ }),
142
+ );
143
+ }
144
+ renderOnly("leftPanel");
145
+ }, 500);
146
+
147
+ clearTimeout(lintDebounce);
148
+ lintDebounce = setTimeout(() => {
149
+ const gen = ++lintGen;
150
+ const currentCode = view.functionEditor.getValue();
151
+ codeService("lint", { code: currentCode, args }).then((result) => {
152
+ if (gen !== lintGen) return;
153
+ if (result?.diagnostics && view.functionEditor)
154
+ setLintMarkers(view.functionEditor, result.diagnostics);
155
+ });
156
+ }, 750);
157
+ });
158
+ }
159
+
160
+ // Register Monaco JS completion provider for state scope variables (once)
161
+ export function registerFunctionCompletions() {
162
+ if (view._completionRegistered) return;
163
+ view._completionRegistered = true;
164
+ monaco.languages.registerCompletionItemProvider("javascript", {
165
+ triggerCharacters: ["."],
166
+ provideCompletionItems(model, position) {
167
+ const S = getState();
168
+ const defs = S?.document?.state || {};
169
+ const word = model.getWordUntilPosition(position);
170
+ const range = {
171
+ startLineNumber: position.lineNumber,
172
+ endLineNumber: position.lineNumber,
173
+ startColumn: word.startColumn,
174
+ endColumn: word.endColumn,
175
+ };
176
+
177
+ const suggestions = Object.entries(defs).map(([key, def]) => {
178
+ let kind = monaco.languages.CompletionItemKind.Variable;
179
+ if (
180
+ /** @type {any} */ (def)?.$prototype === "Function" ||
181
+ /** @type {any} */ (def)?.$handler
182
+ )
183
+ kind = monaco.languages.CompletionItemKind.Function;
184
+ else if (/** @type {any} */ (def)?.$prototype)
185
+ kind = monaco.languages.CompletionItemKind.Property;
186
+ return {
187
+ label: `state.${key}`,
188
+ kind,
189
+ insertText: `state.${key}`,
190
+ range,
191
+ };
192
+ });
193
+ return { suggestions };
194
+ },
195
+ });
196
+ }