@jxsuite/studio 1.0.0 → 1.2.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.
Files changed (89) hide show
  1. package/dist/iframe-entry.js +760 -65
  2. package/dist/iframe-entry.js.map +16 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +71573 -50074
  5. package/dist/studio.js.map +123 -88
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +37 -1
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +193 -6
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +56 -3
  15. package/src/canvas/iframe-render.ts +53 -14
  16. package/src/canvas/site-style-css.ts +121 -0
  17. package/src/collab/collab-session.ts +23 -8
  18. package/src/collab/collab-state.ts +6 -3
  19. package/src/component-props.ts +104 -0
  20. package/src/editor/inline-edit-apply.ts +36 -1
  21. package/src/editor/inline-edit.ts +58 -1
  22. package/src/editor/shortcuts.ts +41 -12
  23. package/src/files/file-ops.ts +14 -0
  24. package/src/files/files.ts +53 -1
  25. package/src/grid/cell-editors.ts +288 -0
  26. package/src/grid/cell-popovers.ts +108 -0
  27. package/src/grid/csv-codec.ts +122 -0
  28. package/src/grid/edit-buffer.ts +490 -0
  29. package/src/grid/grid-controller.ts +417 -0
  30. package/src/grid/grid-layout.ts +83 -0
  31. package/src/grid/grid-open.ts +167 -0
  32. package/src/grid/grid-panel.ts +302 -0
  33. package/src/grid/grid-source.ts +210 -0
  34. package/src/grid/grid-view.ts +367 -0
  35. package/src/grid/schema-columns.ts +261 -0
  36. package/src/grid/sources/connector-source.ts +217 -0
  37. package/src/grid/sources/content-source.ts +542 -0
  38. package/src/grid/sources/csv-file-source.ts +247 -0
  39. package/src/grid/tabulator-tables.d.ts +120 -0
  40. package/src/panels/block-action-bar.ts +8 -3
  41. package/src/panels/chat-panel.ts +98 -0
  42. package/src/panels/data-grid.ts +9 -387
  43. package/src/panels/editors.ts +43 -17
  44. package/src/panels/events-panel.ts +137 -44
  45. package/src/panels/formula-workspace.ts +379 -0
  46. package/src/panels/frontmatter-fields.ts +231 -0
  47. package/src/panels/frontmatter-panel.ts +132 -0
  48. package/src/panels/git-panel.ts +1 -1
  49. package/src/panels/head-panel.ts +11 -175
  50. package/src/panels/properties-panel.ts +154 -144
  51. package/src/panels/right-panel.ts +9 -47
  52. package/src/panels/signals-panel.ts +115 -23
  53. package/src/panels/statement-editor.ts +710 -0
  54. package/src/panels/style-panel.ts +76 -22
  55. package/src/panels/stylebook-panel.ts +0 -2
  56. package/src/panels/tab-bar.ts +214 -10
  57. package/src/panels/tab-strip.ts +62 -6
  58. package/src/panels/toolbar.ts +37 -3
  59. package/src/services/ai-project-tools.ts +541 -0
  60. package/src/services/ai-session-store.ts +28 -0
  61. package/src/services/ai-system-prompt.ts +194 -24
  62. package/src/services/ai-tools.ts +88 -21
  63. package/src/services/automation.ts +16 -0
  64. package/src/services/document-assistant.ts +81 -11
  65. package/src/services/gated-registry.ts +72 -0
  66. package/src/services/live-preview.ts +0 -0
  67. package/src/services/preview-eval.ts +68 -0
  68. package/src/services/project-adoption.ts +31 -0
  69. package/src/settings/css-vars-editor.ts +122 -4
  70. package/src/site-context.ts +2 -0
  71. package/src/store.ts +4 -0
  72. package/src/studio.ts +90 -52
  73. package/src/tabs/patch-ops.ts +25 -0
  74. package/src/tabs/tab.ts +46 -1
  75. package/src/tabs/transact.ts +60 -13
  76. package/src/types.ts +12 -0
  77. package/src/ui/dynamic-slot.ts +272 -0
  78. package/src/ui/expression-editor.ts +423 -125
  79. package/src/ui/field-row.ts +5 -0
  80. package/src/ui/formula-catalog.ts +557 -0
  81. package/src/ui/formula-chips.ts +216 -0
  82. package/src/ui/formula-palette.ts +211 -0
  83. package/src/ui/layers.ts +40 -0
  84. package/src/ui/media-picker.ts +1 -1
  85. package/src/ui/panel-resize.ts +15 -1
  86. package/src/utils/canvas-media.ts +23 -0
  87. package/src/utils/preview-format.ts +26 -0
  88. package/src/view.ts +4 -4
  89. package/src/workspace/workspace.ts +14 -0
@@ -193,6 +193,15 @@ let endFn: (() => void) | null = null; // Function() called when editing stops
193
193
  */
194
194
  let _blurCloseSuspended = false;
195
195
 
196
+ /**
197
+ * Plain (plaintext-only) session state — used for prop-bound text, where the committed value is a
198
+ * plain string (a directive attribute), so rich formatting, Enter-split, and the slash menu are all
199
+ * disabled: Enter commits, Escape restores `_plainOriginal` and commits unchanged (the host no-ops
200
+ * an unchanged prop value).
201
+ */
202
+ let _plainMode = false;
203
+ let _plainOriginal = "";
204
+
196
205
  /** Suspend blur-driven `stopEditing` (call while the parent toolbar/popover may steal focus). */
197
206
  export function suspendBlurClose() {
198
207
  _blurCloseSuspended = true;
@@ -252,6 +261,8 @@ export function isInlineElement(node: JxMutableNode, parentNode?: JxMutableNode)
252
261
  * onInsert: (path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void;
253
262
  * onEnd: () => void;
254
263
  * }} callbacks
264
+ * @param {{ plainText?: boolean }} [opts] - `plainText` runs a plaintext-only session (prop-bound
265
+ * text: no rich formatting/split/slash; Enter commits, Escape cancels)
255
266
  */
256
267
  export function startEditing(
257
268
  el: HTMLElement,
@@ -270,6 +281,7 @@ export function startEditing(
270
281
  onInsert: (path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void;
271
282
  onEnd: () => void;
272
283
  },
284
+ opts?: { plainText?: boolean },
273
285
  ) {
274
286
  if (activeEl) {
275
287
  // Re-enter (e.g. after a split/insert re-render): tear the old session down WITHOUT firing the
@@ -283,9 +295,21 @@ export function startEditing(
283
295
  splitFn = callbacks.onSplit;
284
296
  insertFn = callbacks.onInsert;
285
297
  endFn = callbacks.onEnd;
298
+ _plainMode = opts?.plainText === true;
299
+ _plainOriginal = _plainMode ? (el.textContent ?? "") : "";
286
300
 
287
301
  // Enable editing
288
- el.contentEditable = "true";
302
+ if (_plainMode) {
303
+ try {
304
+ el.contentEditable = "plaintext-only";
305
+ } catch {
306
+ // Engines without plaintext-only support throw on assignment; paste is already plain via
307
+ // HandlePaste, and the keydown plain branch inertifies the format shortcuts.
308
+ el.contentEditable = "true";
309
+ }
310
+ } else {
311
+ el.contentEditable = "true";
312
+ }
289
313
  el.style.pointerEvents = "auto";
290
314
  el.style.outline = "2px solid var(--accent, #4a9eff)";
291
315
  el.style.outlineOffset = "1px";
@@ -338,6 +362,8 @@ export function stopEditing(silent = false) {
338
362
  commitFn = null;
339
363
  splitFn = null;
340
364
  insertFn = null;
365
+ _plainMode = false;
366
+ _plainOriginal = "";
341
367
 
342
368
  if (silent) {
343
369
  endFn = null;
@@ -389,6 +415,30 @@ export function isSlashActive() {
389
415
 
390
416
  /** @param {KeyboardEvent} e */
391
417
  function handleKeydown(e: KeyboardEvent) {
418
+ if (_plainMode) {
419
+ if (e.key === "Escape") {
420
+ e.preventDefault();
421
+ e.stopPropagation();
422
+ if (activeEl) {
423
+ // Cancel: restore the original text; the commit posts the unchanged value (host no-ops).
424
+ activeEl.textContent = _plainOriginal;
425
+ }
426
+ stopEditing();
427
+ return;
428
+ }
429
+ if (e.key === "Enter") {
430
+ e.preventDefault();
431
+ e.stopPropagation();
432
+ stopEditing();
433
+ return;
434
+ }
435
+ // Inertify the browser's native rich shortcuts for the contentEditable="true" fallback.
436
+ if ((e.ctrlKey || e.metaKey) && ["b", "i", "u", "`"].includes(e.key)) {
437
+ e.preventDefault();
438
+ }
439
+ return;
440
+ }
441
+
392
442
  if (e.key === "Escape") {
393
443
  e.preventDefault();
394
444
  e.stopPropagation();
@@ -551,6 +601,13 @@ function commitChanges() {
551
601
  return;
552
602
  }
553
603
 
604
+ if (_plainMode) {
605
+ // A prop value is a plain single-line string (a directive attribute) — flatten any newline
606
+ // That survived plaintext editing and skip the rich DOM→Jx serialization entirely.
607
+ commitFn(activePath, null, (activeEl.textContent ?? "").replaceAll("\n", " "));
608
+ return;
609
+ }
610
+
554
611
  normalizeInlineContent(activeEl);
555
612
  const result = elementToJx(activeEl);
556
613
  commitFn(activePath, result.children ?? null, result.textContent ?? null);
@@ -16,9 +16,11 @@ import {
16
16
  undo as tabUndo,
17
17
  transactDoc,
18
18
  } from "../tabs/transact";
19
+ import { applyEditZoom, requestEditZoom, setEditZoom } from "../canvas/canvas-utils";
19
20
  import { isEditing, stopEditing } from "./inline-edit";
20
21
  import { copyNode, cutNode, pasteNode } from "./context-menu";
21
22
  import { openQuickSearch } from "../panels/quick-search";
23
+ import { shouldWarnOnClose } from "../panels/tab-strip";
22
24
  import { showConfirmDialog } from "../ui/layers";
23
25
  import { rectOf } from "../utils/geometry";
24
26
 
@@ -33,7 +35,6 @@ import type { JxPath } from "../state";
33
35
  * panY: number;
34
36
  * setPan: (x: number, y: number) => void;
35
37
  * applyTransform: () => void;
36
- * positionZoomIndicator: () => void;
37
38
  * saveFile: () => void;
38
39
  * openProject: () => void;
39
40
  * }} getContext
@@ -45,7 +46,6 @@ export function initShortcuts(
45
46
  panY: number;
46
47
  setPan: (x: number, y: number) => void;
47
48
  applyTransform: () => void;
48
- positionZoomIndicator: () => void;
49
49
  saveFile: () => void;
50
50
  openProject: () => void;
51
51
  },
@@ -55,10 +55,18 @@ export function initShortcuts(
55
55
  "wheel",
56
56
  (e: WheelEvent) => {
57
57
  const { canvasMode, panX, panY, setPan, applyTransform } = getContext();
58
- // Edit (content) mode: scroll the edit-mode container ourselves. The canvas iframe is sized to
59
- // Its content (no internal scroll) and a cross-origin OOPIF doesn't bubble wheel to the parent,
60
- // So the wheel reaches us forwarded (or over the canvas chrome) but never triggers native scroll.
58
+ // Edit (content) mode: ctrl/cmd+wheel drives the content zoom (browser-page-zoom semantics —
59
+ // The footprint stays fixed, content reflows); plain wheel scrolls the edit-mode container
60
+ // Ourselves. The canvas iframe is sized to its content (no internal scroll) and a cross-origin
61
+ // OOPIF doesn't bubble wheel to the parent, so the wheel reaches us forwarded (or over the
62
+ // Canvas chrome) but never triggers native scroll.
61
63
  if (canvasMode === "edit") {
64
+ if (e.ctrlKey || e.metaKey) {
65
+ e.preventDefault();
66
+ const editZoom = activeTab.value?.session.ui.editZoom ?? 1;
67
+ requestEditZoom(editZoom * (1 + -e.deltaY * 0.005));
68
+ return;
69
+ }
62
70
  const sc = canvasWrap.querySelector<HTMLElement>(".content-edit-canvas");
63
71
  if (sc) {
64
72
  e.preventDefault();
@@ -125,8 +133,13 @@ export function initShortcuts(
125
133
  canvasWrap.addEventListener("pointerup", onUp);
126
134
  });
127
135
 
128
- // Reposition zoom indicator on resize
129
- window.addEventListener("resize", () => getContext().positionZoomIndicator());
136
+ // Re-fit the edit-mode content zoom on resize: its layout width derives from the LIVE column
137
+ // Width, which tracks the studio window.
138
+ window.addEventListener("resize", () => {
139
+ if (getContext().canvasMode === "edit") {
140
+ applyEditZoom();
141
+ }
142
+ });
130
143
 
131
144
  document.addEventListener("keydown", (e) => {
132
145
  const { canvasMode, setPan, applyTransform, saveFile, openProject } = getContext();
@@ -161,12 +174,17 @@ export function initShortcuts(
161
174
  return;
162
175
  }
163
176
  if (mod) {
177
+ // Grid mode: copy/paste/duplicate/zoom belong to the grid engine (Tabulator clipboard
178
+ // Needs the native events); only tab/app-level chords pass through.
179
+ if (canvasMode === "grid" && !["o", "p", "s", "w", "z", "Z"].includes(e.key)) {
180
+ return;
181
+ }
164
182
  switch (e.key) {
165
183
  case "w": {
166
184
  e.preventDefault();
167
185
  if (workspace.activeTabId && workspace.tabOrder.length > 1) {
168
186
  const tabToClose = workspace.tabs.get(workspace.activeTabId);
169
- if (tabToClose?.doc.dirty) {
187
+ if (tabToClose && shouldWarnOnClose(tabToClose)) {
170
188
  const name = tabToClose.documentPath?.split("/").pop() || "Untitled";
171
189
  void showConfirmDialog(
172
190
  "Unsaved Changes",
@@ -198,7 +216,9 @@ export function initShortcuts(
198
216
  saveFile();
199
217
  break;
200
218
  }
201
- case "z": {
219
+ // With Shift held e.key is "Z", so redo needs the uppercase case too.
220
+ case "z":
221
+ case "Z": {
202
222
  e.preventDefault();
203
223
  if (e.shiftKey) {
204
224
  tabRedo(activeTab.value!);
@@ -231,10 +251,11 @@ export function initShortcuts(
231
251
  break;
232
252
  }
233
253
  case "0": {
254
+ e.preventDefault();
234
255
  if (canvasMode === "edit") {
256
+ setEditZoom(1);
235
257
  break;
236
258
  }
237
- e.preventDefault();
238
259
  activeTab.value!.session.ui.zoom = 1;
239
260
  setPan(16, 16);
240
261
  applyTransform();
@@ -242,19 +263,21 @@ export function initShortcuts(
242
263
  }
243
264
  case "=":
244
265
  case "+": {
266
+ e.preventDefault();
245
267
  if (canvasMode === "edit") {
268
+ setEditZoom((tab?.session.ui.editZoom ?? 1) * 1.2);
246
269
  break;
247
270
  }
248
- e.preventDefault();
249
271
  activeTab.value!.session.ui.zoom = Math.min(5, (tab?.session.ui.zoom ?? 1) * 1.2);
250
272
  applyTransform();
251
273
  break;
252
274
  }
253
275
  case "-": {
276
+ e.preventDefault();
254
277
  if (canvasMode === "edit") {
278
+ setEditZoom((tab?.session.ui.editZoom ?? 1) / 1.2);
255
279
  break;
256
280
  }
257
- e.preventDefault();
258
281
  activeTab.value!.session.ui.zoom = Math.max(0.05, (tab?.session.ui.zoom ?? 1) / 1.2);
259
282
  applyTransform();
260
283
  break;
@@ -266,6 +289,12 @@ export function initShortcuts(
266
289
  return;
267
290
  }
268
291
 
292
+ // Grid mode: Delete/Escape/Enter/arrows drive the grid's own range clearing and cell
293
+ // Navigation — never the canvas document.
294
+ if (canvasMode === "grid") {
295
+ return;
296
+ }
297
+
269
298
  switch (e.key) {
270
299
  case "Delete":
271
300
  case "Backspace": {
@@ -12,6 +12,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
12
12
  import { statusMessage } from "../panels/statusbar";
13
13
  import { validateComponentSlots } from "../services/cem-export";
14
14
  import { getPlatform } from "../platform";
15
+ import { getGridController } from "../grid/grid-controller";
15
16
  import { activeTab, openTab } from "../workspace/workspace";
16
17
  import { collabSave } from "../collab/collab-session";
17
18
  import { isEditing, stopEditing } from "../editor/inline-edit";
@@ -162,6 +163,12 @@ export async function saveFile() {
162
163
  if (!tab) {
163
164
  return;
164
165
  }
166
+ // Grid tabs batch-save through their controller (per-source commit semantics, not a doc write).
167
+ const grid = getGridController(tab);
168
+ if (grid) {
169
+ await grid.save();
170
+ return;
171
+ }
165
172
  try {
166
173
  // A co-edited tab persists through its provider (a direct file write would reset the room).
167
174
  if (await collabSave(tab)) {
@@ -264,6 +271,13 @@ export async function exportFile() {
264
271
  * @returns {Promise<string>}
265
272
  */
266
273
  export async function serializeDocument(tab: Tab): Promise<string> {
274
+ // Grid tabs serialize through their source (pending edits included) — e.g. the Monaco source
275
+ // View of a CSV grid tab shows the live file text.
276
+ const grid = getGridController(tab);
277
+ const gridText = grid?.serializeForSource();
278
+ if (gridText) {
279
+ return gridText;
280
+ }
267
281
  await loadFormats();
268
282
  const sourceFormat = formatByName(tab.doc.sourceFormat);
269
283
  if (sourceFormat?.capabilities.serialize) {
@@ -27,7 +27,16 @@ import {
27
27
  monitorForElements,
28
28
  } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
29
29
  import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
30
- import { activateTab, openTab, renameTab, replaceAllTabs, workspace } from "../workspace/workspace";
30
+ import {
31
+ activateTab,
32
+ openTab,
33
+ renameTab,
34
+ replaceAllTabs,
35
+ setWorkspaceProject,
36
+ workspace,
37
+ } from "../workspace/workspace";
38
+ import { openCollectionGrid, openCsvGridTab, openPagesGrid } from "../grid/grid-open";
39
+ import { collectionDirs } from "../grid/sources/content-source";
31
40
  import { parseSourceForPath, serializeDocument } from "./file-ops";
32
41
  import {
33
42
  documentExtensions,
@@ -98,6 +107,11 @@ export async function loadProject() {
98
107
  searchQuery: "",
99
108
  selectedPath: null,
100
109
  });
110
+ // Only a site project counts as an open project for the workspace (a bare monorepo root keeps
111
+ // The assistant in bootstrap mode).
112
+ if (info.isSiteProject) {
113
+ setWorkspaceProject(meta.root || ".", info.projectConfig || null);
114
+ }
101
115
 
102
116
  if (info.isSiteProject) {
103
117
  addRecentProject(requireProjectState().name, meta.root);
@@ -160,6 +174,7 @@ export async function openProject({
160
174
  searchQuery: "",
161
175
  selectedPath: null,
162
176
  });
177
+ setWorkspaceProject(handle.root, config);
163
178
 
164
179
  await autoSyncProjectOnOpen();
165
180
  await ensureDependenciesInstalled();
@@ -719,6 +734,26 @@ function showFileContextMenu(
719
734
  action: () => createNewFile(entry.path, ctx.renderLeftPanel),
720
735
  label: "New File\u2026",
721
736
  });
737
+ // Directories backing a content collection get a bulk-edit affordance.
738
+ const collection = collectionDirs().find(
739
+ ({ dir }) => entry.path === dir || entry.path.endsWith(`/${dir}`),
740
+ );
741
+ if (collection) {
742
+ items.push({
743
+ action: () => {
744
+ openCollectionGrid(collection.name);
745
+ },
746
+ label: "Edit Collection in Grid",
747
+ });
748
+ }
749
+ if (entry.path === "pages" || entry.path.endsWith("/pages")) {
750
+ items.push({
751
+ action: () => {
752
+ openPagesGrid();
753
+ },
754
+ label: "Edit Pages in Grid",
755
+ });
756
+ }
722
757
  }
723
758
  items.push(
724
759
  { label: "\u2014" },
@@ -1032,6 +1067,23 @@ export async function openFileInTab(path: string) {
1032
1067
  }
1033
1068
  }
1034
1069
 
1070
+ // CSV files open in the grid editor (source mode remains as the raw-text alternate).
1071
+ if (path.toLowerCase().endsWith(".csv")) {
1072
+ try {
1073
+ await openCsvGridTab(path);
1074
+ requireProjectState().selectedPath = path;
1075
+ trackRecentFile({
1076
+ name: path.split("/").pop() || path,
1077
+ path,
1078
+ root: requireProjectState().projectRoot,
1079
+ });
1080
+ statusMessage(`Opened ${path.split("/").pop()}`);
1081
+ } catch (error) {
1082
+ statusMessage(`Error: ${errorMessage(error)}`);
1083
+ }
1084
+ return;
1085
+ }
1086
+
1035
1087
  const platform = getPlatform();
1036
1088
  try {
1037
1089
  const content = await platform.readFile(path);
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Grid cell editors and formatters — the lit → Tabulator bridge.
3
+ *
4
+ * Tabulator's editor contract is `(cell, onRendered, success, cancel) => element`: it parents the
5
+ * returned element into the cell and expects `success(value)`/`cancel()` calls. Each factory here
6
+ * lit-renders a control into a host element supplied by grid-view's single sanctioned element
7
+ * factory — this module itself never creates DOM imperatively. Formatters likewise return a
8
+ * rendered element (never an HTML string, which Tabulator would inject as innerHTML).
9
+ *
10
+ * Native inputs (the data-grid precedent) rather than Spectrum controls: cells are 24px
11
+ * micro-controls where SWC shadow focus handling fights Tabulator's editor lifecycle. Rich popover
12
+ * editors (media, relationship pickers) layer on in a later phase.
13
+ */
14
+ import { html, render } from "lit-html";
15
+ import { cellToText, coerceCellInput } from "./schema-columns";
16
+ import type { GridCellValue, GridColumn } from "./grid-source";
17
+
18
+ /** The subset of Tabulator's CellComponent the editors/formatters touch. */
19
+ export interface CellLike {
20
+ getValue: () => unknown;
21
+ }
22
+
23
+ export type CellEditorFn = (
24
+ cell: CellLike,
25
+ onRendered: (fn: () => void) => void,
26
+ success: (value: unknown) => void,
27
+ cancel: () => void,
28
+ ) => HTMLElement;
29
+
30
+ export type CellFormatterFn = (cell: CellLike) => HTMLElement;
31
+
32
+ /** Detached-host factory — grid-view owns the one sanctioned document.createElement. */
33
+ export type HostFactory = (className: string) => HTMLElement;
34
+
35
+ /** Commit/cancel wiring shared by every text-ish editor. */
36
+ function inputEditor(makeHost: HostFactory, column: GridColumn, inputType: string): CellEditorFn {
37
+ return (cell, onRendered, success, cancel) => {
38
+ const host = makeHost("jx-grid-editor");
39
+ let done = false;
40
+ const commit = (raw: string) => {
41
+ if (!done) {
42
+ done = true;
43
+ success(coerceCellInput(raw, column));
44
+ }
45
+ };
46
+ const abort = () => {
47
+ if (!done) {
48
+ done = true;
49
+ cancel();
50
+ }
51
+ };
52
+ const initial =
53
+ column.kind === "date"
54
+ ? cellToText(cell.getValue() as GridCellValue).slice(0, 10)
55
+ : cellToText(cell.getValue() as GridCellValue);
56
+ render(
57
+ html`<input
58
+ class="jx-grid-input"
59
+ type=${inputType}
60
+ .value=${initial}
61
+ @keydown=${(e: KeyboardEvent) => {
62
+ if (e.key === "Enter") {
63
+ commit((e.target as HTMLInputElement).value);
64
+ } else if (e.key === "Escape") {
65
+ abort();
66
+ }
67
+ }}
68
+ @blur=${(e: Event) => commit((e.target as HTMLInputElement).value)}
69
+ />`,
70
+ host,
71
+ );
72
+ onRendered(() => {
73
+ const input = host.querySelector("input");
74
+ input?.focus();
75
+ input?.select();
76
+ });
77
+ return host;
78
+ };
79
+ }
80
+
81
+ function checkboxEditor(makeHost: HostFactory): CellEditorFn {
82
+ return (cell, onRendered, success, cancel) => {
83
+ const host = makeHost("jx-grid-editor");
84
+ let done = false;
85
+ render(
86
+ html`<input
87
+ class="jx-grid-checkbox"
88
+ type="checkbox"
89
+ .checked=${cell.getValue() === true}
90
+ @change=${(e: Event) => {
91
+ if (!done) {
92
+ done = true;
93
+ success((e.target as HTMLInputElement).checked);
94
+ }
95
+ }}
96
+ @keydown=${(e: KeyboardEvent) => {
97
+ if (e.key === "Escape" && !done) {
98
+ done = true;
99
+ cancel();
100
+ } else if (e.key === "Enter" && !done) {
101
+ done = true;
102
+ success((e.target as HTMLInputElement).checked);
103
+ }
104
+ }}
105
+ @blur=${(e: Event) => {
106
+ if (!done) {
107
+ done = true;
108
+ success((e.target as HTMLInputElement).checked);
109
+ }
110
+ }}
111
+ />`,
112
+ host,
113
+ );
114
+ onRendered(() => host.querySelector("input")?.focus());
115
+ return host;
116
+ };
117
+ }
118
+
119
+ function selectEditor(makeHost: HostFactory, column: GridColumn): CellEditorFn {
120
+ const options = Array.isArray(column.schema?.enum)
121
+ ? (column.schema.enum as unknown[]).map(String)
122
+ : [];
123
+ return (cell, onRendered, success, cancel) => {
124
+ const host = makeHost("jx-grid-editor");
125
+ let done = false;
126
+ const current = cellToText(cell.getValue() as GridCellValue);
127
+ render(
128
+ html`<select
129
+ class="jx-grid-select"
130
+ @change=${(e: Event) => {
131
+ if (!done) {
132
+ done = true;
133
+ const { value } = e.target as HTMLSelectElement;
134
+ success(value === "" ? null : value);
135
+ }
136
+ }}
137
+ @keydown=${(e: KeyboardEvent) => {
138
+ if (e.key === "Escape" && !done) {
139
+ done = true;
140
+ cancel();
141
+ }
142
+ }}
143
+ @blur=${() => {
144
+ if (!done) {
145
+ done = true;
146
+ cancel();
147
+ }
148
+ }}
149
+ >
150
+ <option value="" ?selected=${current === ""}>—</option>
151
+ ${options.map(
152
+ (option) =>
153
+ html`<option value=${option} ?selected=${option === current}>${option}</option>`,
154
+ )}
155
+ </select>`,
156
+ host,
157
+ );
158
+ onRendered(() => host.querySelector("select")?.focus());
159
+ return host;
160
+ };
161
+ }
162
+
163
+ /** Pill/chip editor for string arrays: Enter/comma adds, Backspace pops, × removes. */
164
+ function pillEditor(makeHost: HostFactory, column: GridColumn): CellEditorFn {
165
+ return (cell, onRendered, success, cancel) => {
166
+ const host = makeHost("jx-grid-editor jx-grid-pill-editor");
167
+ const initial = coerceCellInput(cell.getValue(), column);
168
+ const chips: string[] = Array.isArray(initial) ? [...initial] : [];
169
+ let done = false;
170
+ const finish = (committed: string[] | null) => {
171
+ if (done) {
172
+ return;
173
+ }
174
+ done = true;
175
+ if (committed) {
176
+ success(committed);
177
+ } else {
178
+ cancel();
179
+ }
180
+ };
181
+ const inputEl = () => host.querySelector("input");
182
+ const addFromInput = (input: HTMLInputElement): boolean => {
183
+ const text = input.value.trim();
184
+ if (text === "") {
185
+ return false;
186
+ }
187
+ chips.push(text);
188
+ input.value = "";
189
+ return true;
190
+ };
191
+ const doRender = () => {
192
+ render(
193
+ html`<span class="jx-grid-pills">
194
+ ${chips.map(
195
+ (chip, i) => html`<span class="jx-grid-chip"
196
+ >${chip}<button
197
+ class="jx-grid-chip-x"
198
+ title="Remove"
199
+ @mousedown=${(e: Event) => e.preventDefault()}
200
+ @click=${() => {
201
+ chips.splice(i, 1);
202
+ doRender();
203
+ inputEl()?.focus();
204
+ }}
205
+ >
206
+ ×
207
+ </button></span
208
+ >`,
209
+ )}
210
+ <input
211
+ class="jx-grid-pill-input"
212
+ placeholder=${chips.length === 0 ? "add…" : ""}
213
+ @keydown=${(e: KeyboardEvent) => {
214
+ const input = e.target as HTMLInputElement;
215
+ if (e.key === "Enter" || e.key === ",") {
216
+ e.preventDefault();
217
+ if (addFromInput(input)) {
218
+ doRender();
219
+ inputEl()?.focus();
220
+ } else if (e.key === "Enter") {
221
+ finish([...chips]);
222
+ }
223
+ } else if (e.key === "Backspace" && input.value === "" && chips.length > 0) {
224
+ chips.pop();
225
+ doRender();
226
+ inputEl()?.focus();
227
+ } else if (e.key === "Escape") {
228
+ finish(null);
229
+ }
230
+ }}
231
+ @blur=${(e: Event) => {
232
+ addFromInput(e.target as HTMLInputElement);
233
+ finish([...chips]);
234
+ }}
235
+ />
236
+ </span>`,
237
+ host,
238
+ );
239
+ };
240
+ doRender();
241
+ onRendered(() => inputEl()?.focus());
242
+ return host;
243
+ };
244
+ }
245
+
246
+ /** Editor factory for a column; undefined means the column is not editable in place. */
247
+ export function editorForColumn(
248
+ column: GridColumn,
249
+ makeHost: HostFactory,
250
+ ): CellEditorFn | undefined {
251
+ if (column.kind === "readonly" || (!column.editable && !column.insertOnly)) {
252
+ return undefined;
253
+ }
254
+ switch (column.kind) {
255
+ case "boolean": {
256
+ return checkboxEditor(makeHost);
257
+ }
258
+ case "enum": {
259
+ return selectEditor(makeHost, column);
260
+ }
261
+ case "date": {
262
+ return inputEditor(makeHost, column, "date");
263
+ }
264
+ case "array": {
265
+ return pillEditor(makeHost, column);
266
+ }
267
+ default: {
268
+ // String/text/number/image/reference — text input + column-typed coercion.
269
+ return inputEditor(makeHost, column, "text");
270
+ }
271
+ }
272
+ }
273
+
274
+ /** Formatter factory — plain-text projection rendered as a real element (innerHTML-safe). */
275
+ export function formatterForColumn(column: GridColumn, makeHost: HostFactory): CellFormatterFn {
276
+ return (cell) => {
277
+ const value = cell.getValue() as GridCellValue;
278
+ const host = makeHost(`jx-grid-cell-content jx-grid-kind-${column.kind}`);
279
+ if (column.kind === "boolean") {
280
+ render(html`${value === true ? "✓" : ""}`, host);
281
+ } else if (column.kind === "array" && Array.isArray(value)) {
282
+ render(html`${value.map((item) => html`<span class="jx-grid-chip">${item}</span>`)}`, host);
283
+ } else {
284
+ render(html`${cellToText(value)}`, host);
285
+ }
286
+ return host;
287
+ };
288
+ }