@hanzo/ui 8.27.57 → 8.27.59

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 (54) hide show
  1. package/dist/backends/gui/code-editor.cjs +374 -71
  2. package/dist/backends/gui/code-editor.d.ts +46 -25
  3. package/dist/backends/gui/code-editor.d.ts.map +1 -1
  4. package/dist/backends/gui/code-editor.js +376 -73
  5. package/dist/backends/gui/code-editor.js.map +1 -1
  6. package/dist/backends/gui/index.cjs +9 -4
  7. package/dist/backends/gui/index.d.ts +3 -1
  8. package/dist/backends/gui/index.d.ts.map +1 -1
  9. package/dist/backends/gui/index.js +2 -0
  10. package/dist/backends/gui/index.js.map +1 -1
  11. package/dist/backends/gui/json-tree.cjs +135 -0
  12. package/dist/backends/gui/json-tree.d.ts +26 -0
  13. package/dist/backends/gui/json-tree.d.ts.map +1 -0
  14. package/dist/backends/gui/json-tree.js +100 -0
  15. package/dist/backends/gui/json-tree.js.map +1 -0
  16. package/dist/backends/gui/json.cjs +183 -0
  17. package/dist/backends/gui/json.d.ts +41 -0
  18. package/dist/backends/gui/json.d.ts.map +1 -0
  19. package/dist/backends/gui/json.js +180 -0
  20. package/dist/backends/gui/json.js.map +1 -0
  21. package/dist/gallery.html +7 -5
  22. package/dist/index.cjs +8 -4
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.node.cjs +8 -4
  28. package/dist/index.node.js +1 -1
  29. package/dist/primitives/JsonTree.cjs +8 -0
  30. package/dist/primitives/JsonTree.d.ts +2 -0
  31. package/dist/primitives/JsonTree.d.ts.map +1 -0
  32. package/dist/primitives/JsonTree.js +5 -0
  33. package/dist/primitives/JsonTree.js.map +1 -0
  34. package/dist/primitives/Picker.cjs +8 -0
  35. package/dist/primitives/Picker.d.ts +2 -0
  36. package/dist/primitives/Picker.d.ts.map +1 -0
  37. package/dist/primitives/Picker.js +5 -0
  38. package/dist/primitives/Picker.js.map +1 -0
  39. package/dist/primitives/checkJson.cjs +8 -0
  40. package/dist/primitives/checkJson.d.ts +2 -0
  41. package/dist/primitives/checkJson.d.ts.map +1 -0
  42. package/dist/primitives/checkJson.js +5 -0
  43. package/dist/primitives/checkJson.js.map +1 -0
  44. package/dist/primitives/formatJson.cjs +8 -0
  45. package/dist/primitives/formatJson.d.ts +2 -0
  46. package/dist/primitives/formatJson.d.ts.map +1 -0
  47. package/dist/primitives/formatJson.js +5 -0
  48. package/dist/primitives/formatJson.js.map +1 -0
  49. package/dist/product/limits.cjs +10 -1
  50. package/dist/product/limits.d.ts.map +1 -1
  51. package/dist/product/limits.js +10 -1
  52. package/dist/product/limits.js.map +1 -1
  53. package/dist/styles.css +4 -2
  54. package/package.json +7 -1
@@ -37,26 +37,41 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.CodeEditor = CodeEditor;
38
38
  const jsx_runtime_1 = require("react/jsx-runtime");
39
39
  /**
40
- * CodeEditor — a bordered, monospace editing surface: a toolbar carrying a
41
- * language menu and a copy-to-clipboard button, above a plain-text field with
42
- * a gutter of line numbers that scrolls with it.
40
+ * CodeEditor — a CodeMirror 6 editor in a gui frame: a toolbar with the
41
+ * language (a menu, or a label), Format for JSON and Copy; the editor with a
42
+ * line-number gutter; and a footer that reports whether JSON parses and is the
43
+ * handle that resizes the editor.
43
44
  *
44
- * The field is gui's `TextArea`, so what is edited is the text itself. Tab
45
- * inserts two spaces and Escape releases focus; `wordWrap` is the field's own
46
- * white-space; the gutter numbers lines of text, so a line that wraps keeps
47
- * one number; there is no syntax colouring, and `minimap` is accepted and
48
- * ignored because a plain field has no surface to draw one on.
45
+ * Height. With no `height` the editor is as tall as its text, from `minHeight`
46
+ * up to `maxHeight`, and scrolls past that. Dragging the footer (or ArrowUp /
47
+ * ArrowDown on it) sets a height of the reader's own, past the cap if they
48
+ * want; a double-click hands the height back to the text. A `height` fixes it,
49
+ * and `'100%'` fills a frame that is itself sized (a flex child).
49
50
  *
50
- * `theme` is the editor's own palette, independent of the surrounding gui
51
- * theme the way a syntax theme is: `'light'` and `'dark'` paint a fixed set of
52
- * literal colours, and `'auto'` (the default) takes the ambient `$background`,
53
- * `$color` and `$borderColor` tokens, so it follows the host page.
51
+ * JSON. `language="json"` colours the syntax and checks the text on every
52
+ * change with `checkJson`: a broken document tints the failing line, marks the
53
+ * failing character and names line, column and reason in the footer. With
54
+ * `allowText`, text that does not open an object or an array is plain text and
55
+ * passes. Format (or Shift-Alt-F) re-indents valid JSON by two spaces. Other
56
+ * languages are edited as plain text: the package carries one grammar.
57
+ *
58
+ * Colour. Every colour is a theme rung over a design token — the ink, the
59
+ * hairline, the selection and the error state — so the editor follows the page
60
+ * into light or dark with nothing to configure. Hanzo is monochrome, so the syntax is ranked
61
+ * by ink and weight, not by hue: property names in full ink, strings one rank
62
+ * down, punctuation at the rank of a hint. Hue is spent only on an error.
54
63
  */
55
64
  const gui_1 = require("@hanzo/gui");
56
65
  const lucide_icons_2_1 = require("@hanzogui/lucide-icons-2");
66
+ const commands_1 = require("@codemirror/commands");
67
+ const lang_json_1 = require("@codemirror/lang-json");
68
+ const language_1 = require("@codemirror/language");
69
+ const state_1 = require("@codemirror/state");
70
+ const view_1 = require("@codemirror/view");
71
+ const highlight_1 = require("@lezer/highlight");
57
72
  const React = __importStar(require("react"));
58
73
  const button_1 = require("./button.cjs");
59
- const control_1 = require("./control.cjs");
74
+ const json_1 = require("./json.cjs");
60
75
  const select_1 = require("./select.cjs");
61
76
  const slot_1 = require("./slot.cjs");
62
77
  const toaster_1 = require("./toaster.cjs");
@@ -113,53 +128,332 @@ const LABEL = {
113
128
  shell: 'Shell',
114
129
  plaintext: 'Plain Text',
115
130
  };
116
- /** The two named palettes; `'auto'` paints none of this. */
117
- const PALETTE = {
118
- light: { bg: '#ffffff', fg: '#1f2328', border: '#d0d7de', head: '#f6f8fa', gutter: '#8c959f' },
119
- dark: { bg: '#1e1e1e', fg: '#d4d4d4', border: '#3c3c3c', head: '#252526', gutter: '#858585' },
120
- };
121
- /** What Tab inserts. */
122
- const INDENT = ' ';
123
131
  /** Line height as a multiple of the font size. */
124
- const LEADING = 1.5;
125
- function CodeEditor({ value, defaultValue = '', language = 'javascript', height = '400px', theme = 'auto', onChange, onMount, readOnly = false, lineNumbers = true, minimap: _minimap, wordWrap = 'on', fontSize = 14, showCopyButton = true, showLanguageSelector = true, availableLanguages = LANGUAGES, label = 'Code', ...props }) {
126
- const [typed, setTyped] = React.useState(defaultValue);
127
- const text = value ?? typed;
132
+ const LEADING = 1.6;
133
+ /** How far one ArrowUp / ArrowDown on the footer moves the height. */
134
+ const STEP = 24;
135
+ const MONO = "var(--font-mono, 'Zen Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)";
136
+ /**
137
+ * The editor's surface. Every colour is one of the theme's named rungs — gui emits
138
+ * each as a custom property on the theme class, so it inverts with the page — and
139
+ * those rungs read @hanzo/design's tokens (`--ink` is `--foreground`, `--edge` is
140
+ * `--border`, `--bad` is `--state-error`). The design ladder is NOT read directly:
141
+ * on a host that mounts design's sheet without its light selector, `--text-primary`
142
+ * stays the dark theme's white on a light page.
143
+ */
144
+ const FRAME = /* @__PURE__ */ view_1.EditorView.theme({
145
+ '&': { color: 'var(--ink)', backgroundColor: 'transparent' },
146
+ '&.cm-focused': { outline: 'none' },
147
+ // The scroller fills what the editor is given and no more: grown with its text up to the
148
+ // editor's max-height, then scrolling; stretched to a fixed or dragged height. CodeMirror's
149
+ // own `height: 100%` resolves against an auto-height editor as its min-height, which pinned
150
+ // a growing editor at its floor.
151
+ '.cm-scroller': {
152
+ fontFamily: MONO,
153
+ lineHeight: String(LEADING),
154
+ overflow: 'auto',
155
+ flex: '1 1 auto',
156
+ height: 'auto',
157
+ minHeight: '0',
158
+ },
159
+ '.cm-content': { padding: '8px 0', caretColor: 'var(--ink)' },
160
+ '.cm-line': { padding: '0 12px' },
161
+ '.cm-gutters': {
162
+ backgroundColor: 'transparent',
163
+ color: 'var(--dim)',
164
+ border: 'none',
165
+ borderRight: '1px solid var(--edge)',
166
+ },
167
+ '.cm-lineNumbers .cm-gutterElement': { padding: '0 10px 0 12px', minWidth: '28px' },
168
+ '.cm-activeLine': { backgroundColor: 'var(--panel)' },
169
+ '.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--quiet)' },
170
+ '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--ink)' },
171
+ '.cm-selectionBackground, &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, ::selection': {
172
+ backgroundColor: 'var(--raised)',
173
+ },
174
+ '.cm-matchingBracket, &.cm-focused .cm-matchingBracket': {
175
+ backgroundColor: 'var(--raised)',
176
+ color: 'inherit',
177
+ outline: 'none',
178
+ },
179
+ '.cm-nonmatchingBracket, &.cm-focused .cm-nonmatchingBracket': { backgroundColor: 'transparent' },
180
+ '.cm-placeholder': { color: 'var(--dim)' },
181
+ '.cm-fault': { backgroundColor: 'var(--state-error-bg, rgb(239 68 68 / .1))' },
182
+ '.cm-gutterElement.cm-fault': { color: 'var(--bad)' },
183
+ '.cm-fault-at': {
184
+ textDecoration: 'underline wavy var(--bad)',
185
+ textDecorationSkipInk: 'none',
186
+ textUnderlineOffset: '3px',
187
+ },
188
+ });
189
+ /** The syntax, ranked by ink: names in full ink, strings a rank down, punctuation at a hint. */
190
+ const INK = /* @__PURE__ */ language_1.HighlightStyle.define([
191
+ { tag: highlight_1.tags.propertyName, color: 'var(--ink)', fontWeight: '500' },
192
+ { tag: highlight_1.tags.string, color: 'var(--soft)' },
193
+ { tag: [highlight_1.tags.number, highlight_1.tags.bool, highlight_1.tags.null], color: 'var(--ink)' },
194
+ { tag: [highlight_1.tags.punctuation, highlight_1.tags.separator, highlight_1.tags.brace, highlight_1.tags.squareBracket], color: 'var(--dim)' },
195
+ ]);
196
+ /** How much deeper than its line a wrapped continuation starts, in characters. */
197
+ const HANG = 2;
198
+ /** A line's own indentation in characters, a tab counted as two. */
199
+ const indentOf = (text) => {
200
+ let n = 0;
201
+ for (const c of text) {
202
+ if (c === ' ')
203
+ n += 1;
204
+ else if (c === '\t')
205
+ n += 2;
206
+ else
207
+ break;
208
+ }
209
+ return n;
210
+ };
211
+ const hangs = new Map();
212
+ const hangFor = (n) => {
213
+ let d = hangs.get(n);
214
+ if (!d) {
215
+ d = view_1.Decoration.line({ attributes: { style: `padding-left: calc(${n}ch + 12px); text-indent: -${n}ch` } });
216
+ hangs.set(n, d);
217
+ }
218
+ return d;
219
+ };
220
+ /**
221
+ * Wrapped lines hang under their own text rather than restarting at the margin, so a
222
+ * long string inside nested JSON still reads as belonging to its key. A line's padding
223
+ * is its indentation plus the hang, and a negative text-indent of the same width puts
224
+ * its first row back where it was.
225
+ */
226
+ const hang = /* @__PURE__ */ view_1.ViewPlugin.fromClass(class {
227
+ decorations;
228
+ constructor(view) {
229
+ this.decorations = this.build(view);
230
+ }
231
+ update(u) {
232
+ if (u.docChanged || u.viewportChanged)
233
+ this.decorations = this.build(u.view);
234
+ }
235
+ build(view) {
236
+ const out = new state_1.RangeSetBuilder();
237
+ for (const { from, to } of view.visibleRanges) {
238
+ for (let pos = from; pos <= to;) {
239
+ const line = view.state.doc.lineAt(pos);
240
+ out.add(line.from, line.from, hangFor(indentOf(line.text) + HANG));
241
+ pos = line.to + 1;
242
+ }
243
+ }
244
+ return out.finish();
245
+ }
246
+ }, { decorations: (v) => v.decorations });
247
+ /** Marks a programmatic edit, so a caller's own `value` is never echoed back to `onChange`. */
248
+ const External = /* @__PURE__ */ state_1.Annotation.define();
249
+ class Fault extends view_1.GutterMarker {
250
+ elementClass = 'cm-fault';
251
+ }
252
+ const FAULT_GUTTER = /* @__PURE__ */ new Fault();
253
+ const FAULT_LINE = /* @__PURE__ */ view_1.Decoration.line({ class: 'cm-fault' });
254
+ const FAULT_AT = /* @__PURE__ */ view_1.Decoration.mark({ class: 'cm-fault-at' });
255
+ function judge(state, text) {
256
+ const check = (0, json_1.checkJson)(state.doc.toString(), { text });
257
+ if (check.kind !== 'error')
258
+ return { check, marks: view_1.Decoration.none, gutter: state_1.RangeSet.empty };
259
+ const at = Math.min(check.at, state.doc.length);
260
+ const line = state.doc.lineAt(at);
261
+ const end = Math.min(at + 1, line.to);
262
+ const ranges = [FAULT_LINE.range(line.from), ...(end > at ? [FAULT_AT.range(at, end)] : [])];
263
+ return { check, marks: view_1.Decoration.set(ranges, true), gutter: state_1.RangeSet.of([FAULT_GUTTER.range(line.from)]) };
264
+ }
265
+ /** The JSON verdict as editor state: recomputed on every edit, drawn as decorations. */
266
+ const verdict = (text) => state_1.StateField.define({
267
+ create: (state) => judge(state, text),
268
+ update: (value, tr) => (tr.docChanged ? judge(tr.state, text) : value),
269
+ provide: (field) => [
270
+ view_1.EditorView.decorations.from(field, (v) => v.marks),
271
+ view_1.gutterLineClass.from(field, (v) => v.gutter),
272
+ ],
273
+ });
274
+ const px = (v) => (typeof v === 'number' ? `${v}px` : v);
275
+ function CodeEditor({ value, defaultValue = '', language = 'javascript', height, minHeight = 96, maxHeight = 480, resizable = height === undefined, allowText = false, onChange, onCheck, onMount, readOnly = false, lineNumbers = true, wordWrap = 'on', fontSize = 14, placeholder, showCopyButton = true, showFormatButton, showLanguageSelector = true, availableLanguages = LANGUAGES, label = 'Code', ...props }) {
128
276
  const [selected, setSelected] = React.useState(language);
277
+ const [text, setText] = React.useState(value ?? defaultValue);
278
+ const [check, setCheck] = React.useState(null);
129
279
  const [copied, setCopied] = React.useState(false);
130
- const [scrollTop, setScrollTop] = React.useState(0);
131
- const field = React.useRef(null);
280
+ const [dragged, setDragged] = React.useState(null);
281
+ const host = React.useRef(null);
282
+ const grip = React.useRef(null);
283
+ const view = React.useRef(null);
132
284
  const timer = React.useRef(undefined);
133
- React.useEffect(() => {
134
- if (field.current)
135
- onMount?.(field.current);
136
- return () => clearTimeout(timer.current);
285
+ const listeners = React.useRef({ onChange, onCheck });
286
+ listeners.current = { onChange, onCheck };
287
+ const compartments = React.useMemo(() => ({ language: new state_1.Compartment(), gutter: new state_1.Compartment(), wrap: new state_1.Compartment(), edit: new state_1.Compartment() }), []);
288
+ const isJson = selected === 'json';
289
+ const field = React.useMemo(() => verdict(allowText), [allowText]);
290
+ const judged = React.useRef({ field });
291
+ judged.current.field = field;
292
+ const grammar = () => (isJson ? [(0, lang_json_1.json)(), field] : []);
293
+ const gutter = () => (lineNumbers ? [(0, view_1.lineNumbers)(), (0, view_1.highlightActiveLineGutter)()] : []);
294
+ const wrap = () => (wordWrap === 'off' ? [] : [view_1.EditorView.lineWrapping, hang]);
295
+ const edit = () => [state_1.EditorState.readOnly.of(readOnly), view_1.EditorView.editable.of(!readOnly)];
296
+ /** Report the JSON verdict when it changed (an edit, or a new grammar). */
297
+ const report = (state) => {
298
+ const v = state.field(judged.current.field, false);
299
+ if (v === judged.current.seen)
300
+ return;
301
+ judged.current.seen = v;
302
+ setCheck(v ? v.check : null);
303
+ if (v)
304
+ listeners.current.onCheck?.(v.check);
305
+ };
306
+ const format = React.useCallback(() => {
307
+ const v = view.current;
308
+ if (!v || v.state.readOnly)
309
+ return false;
310
+ const current = v.state.doc.toString();
311
+ const next = (0, json_1.formatJson)(current);
312
+ if (next === null || next === current)
313
+ return false;
314
+ v.dispatch({ changes: { from: 0, to: current.length, insert: next } });
315
+ return true;
137
316
  }, []);
138
- // Bound directly rather than through the gui field's `onScroll` prop: native
139
- // `scroll` does not bubble, and a listener attached this way fires however
140
- // the offset changed — a real drag or a test stating it outright.
141
317
  React.useEffect(() => {
142
- const el = field.current;
143
- if (!el)
318
+ const parent = host.current;
319
+ if (!parent)
144
320
  return;
145
- const onScroll = () => setScrollTop(el.scrollTop);
146
- el.addEventListener('scroll', onScroll);
147
- return () => el.removeEventListener('scroll', onScroll);
321
+ const listen = view_1.EditorView.updateListener.of((update) => {
322
+ if (update.docChanged && !update.transactions.some((tr) => tr.annotation(External))) {
323
+ const next = update.state.doc.toString();
324
+ setText(next);
325
+ listeners.current.onChange?.(next);
326
+ }
327
+ report(update.state);
328
+ });
329
+ const editor = new view_1.EditorView({
330
+ parent,
331
+ state: state_1.EditorState.create({
332
+ doc: value ?? defaultValue,
333
+ extensions: [
334
+ compartments.gutter.of(gutter()),
335
+ (0, commands_1.history)(),
336
+ (0, view_1.drawSelection)(),
337
+ (0, language_1.indentOnInput)(),
338
+ (0, language_1.bracketMatching)(),
339
+ (0, view_1.highlightActiveLine)(),
340
+ (0, language_1.syntaxHighlighting)(INK),
341
+ view_1.keymap.of([
342
+ { key: 'Shift-Alt-f', run: format },
343
+ commands_1.indentWithTab,
344
+ ...commands_1.defaultKeymap,
345
+ ...commands_1.historyKeymap,
346
+ {
347
+ key: 'Escape',
348
+ run: (v) => {
349
+ v.contentDOM.blur();
350
+ return true;
351
+ },
352
+ },
353
+ ]),
354
+ compartments.language.of(grammar()),
355
+ compartments.wrap.of(wrap()),
356
+ compartments.edit.of(edit()),
357
+ placeholder ? (0, view_1.placeholder)(placeholder) : [],
358
+ view_1.EditorView.contentAttributes.of({ 'aria-label': label, 'data-slot': 'code-editor-content' }),
359
+ FRAME,
360
+ listen,
361
+ ],
362
+ }),
363
+ });
364
+ view.current = editor;
365
+ report(editor.state);
366
+ onMount?.(editor);
367
+ return () => {
368
+ editor.destroy();
369
+ view.current = null;
370
+ clearTimeout(timer.current);
371
+ };
148
372
  }, []);
149
- const change = React.useCallback((next) => {
150
- setTyped(next);
151
- onChange?.(next);
152
- }, [onChange]);
153
- const key = (e) => {
154
- const el = e.currentTarget;
155
- if (e.key === 'Escape')
156
- el.blur();
157
- if (e.key !== 'Tab' || e.shiftKey || readOnly)
373
+ // Props that reshape the editor reach it as a reconfiguration, not a remount,
374
+ // so the caret, the selection and the undo history survive.
375
+ React.useEffect(() => {
376
+ view.current?.dispatch({
377
+ effects: [
378
+ compartments.language.reconfigure(grammar()),
379
+ compartments.gutter.reconfigure(gutter()),
380
+ compartments.wrap.reconfigure(wrap()),
381
+ compartments.edit.reconfigure(edit()),
382
+ ],
383
+ });
384
+ }, [isJson, field, lineNumbers, wordWrap, readOnly]);
385
+ // A controlled value that moved out from under the editor replaces the text.
386
+ React.useEffect(() => {
387
+ const v = view.current;
388
+ if (!v || value === undefined)
158
389
  return;
159
- e.preventDefault();
160
- el.setRangeText(INDENT, el.selectionStart ?? 0, el.selectionEnd ?? 0, 'end');
161
- change(el.value);
162
- };
390
+ const current = v.state.doc.toString();
391
+ if (value === current)
392
+ return;
393
+ v.dispatch({ changes: { from: 0, to: current.length, insert: value }, annotations: External.of(true) });
394
+ setText(value);
395
+ }, [value]);
396
+ React.useEffect(() => {
397
+ const dom = view.current?.dom;
398
+ if (!dom)
399
+ return;
400
+ dom.style.fontSize = `${fontSize}px`;
401
+ const fixed = dragged !== null ? `${dragged}px` : height !== undefined ? px(height) : '';
402
+ dom.style.height = fixed;
403
+ dom.style.minHeight = fixed ? '' : `${minHeight}px`;
404
+ dom.style.maxHeight = fixed ? '' : `${maxHeight}px`;
405
+ view.current?.requestMeasure();
406
+ }, [dragged, height, minHeight, maxHeight, fontSize]);
407
+ React.useEffect(() => {
408
+ const el = grip.current;
409
+ if (!el || !resizable)
410
+ return;
411
+ let pointer = -1;
412
+ let startY = 0;
413
+ let startH = 0;
414
+ const current = () => view.current?.dom.getBoundingClientRect().height ?? minHeight;
415
+ const down = (e) => {
416
+ if (e.button !== 0)
417
+ return;
418
+ e.preventDefault();
419
+ pointer = e.pointerId;
420
+ startY = e.clientY;
421
+ startH = current();
422
+ el.setPointerCapture?.(pointer);
423
+ };
424
+ const move = (e) => {
425
+ if (e.pointerId !== pointer)
426
+ return;
427
+ setDragged(Math.max(minHeight, Math.round(startH + e.clientY - startY)));
428
+ };
429
+ const up = (e) => {
430
+ if (e.pointerId !== pointer)
431
+ return;
432
+ el.releasePointerCapture?.(pointer);
433
+ pointer = -1;
434
+ };
435
+ const key = (e) => {
436
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')
437
+ return;
438
+ e.preventDefault();
439
+ setDragged(Math.max(minHeight, Math.round(current() + (e.key === 'ArrowDown' ? STEP : -STEP))));
440
+ };
441
+ const reset = () => setDragged(null);
442
+ el.addEventListener('pointerdown', down);
443
+ el.addEventListener('pointermove', move);
444
+ el.addEventListener('pointerup', up);
445
+ el.addEventListener('pointercancel', up);
446
+ el.addEventListener('keydown', key);
447
+ el.addEventListener('dblclick', reset);
448
+ return () => {
449
+ el.removeEventListener('pointerdown', down);
450
+ el.removeEventListener('pointermove', move);
451
+ el.removeEventListener('pointerup', up);
452
+ el.removeEventListener('pointercancel', up);
453
+ el.removeEventListener('keydown', key);
454
+ el.removeEventListener('dblclick', reset);
455
+ };
456
+ }, [resizable, minHeight]);
163
457
  const copy = React.useCallback(async () => {
164
458
  try {
165
459
  await navigator.clipboard.writeText(text);
@@ -172,24 +466,33 @@ function CodeEditor({ value, defaultValue = '', language = 'javascript', height
172
466
  toaster_1.toast.error('Failed to copy code');
173
467
  }
174
468
  }, [text]);
175
- const named = theme === 'auto' ? undefined : PALETTE[theme];
176
- const lineHeight = `${fontSize * LEADING}px`;
177
- const wrap = wordWrap !== 'off';
178
- const gutter = Array.from({ length: text.split('\n').length }, (_, i) => i + 1).join('\n');
179
- return ((0, jsx_runtime_1.jsxs)(gui_1.YStack, { ...(0, slot_1.slot)('code-editor'), borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", bg: "$background", style: named && { backgroundColor: named.bg, borderColor: named.border }, ...props, children: [(showLanguageSelector || showCopyButton) && ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('code-editor-toolbar'), items: "center", justify: showLanguageSelector ? 'space-between' : 'flex-end', borderBottomWidth: 1, borderColor: "$borderColor", bg: "$panel", px: "$3", py: "$2", style: named && { backgroundColor: named.head, borderColor: named.border }, children: [showLanguageSelector && ((0, jsx_runtime_1.jsxs)(select_1.Select, { value: selected, onValueChange: setSelected, children: [(0, jsx_runtime_1.jsx)(select_1.SelectTrigger, { ...(0, slot_1.slot)('code-editor-language-trigger'), width: "auto", minHeight: 32, borderWidth: 0, bg: "transparent", gap: "$1.5", px: "$2", children: (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$1", fontFamily: "$mono", style: named && { color: named.fg }, children: LABEL[selected] ?? selected }) }), (0, jsx_runtime_1.jsx)(select_1.SelectContent, { ...(0, slot_1.slot)('code-editor-language-content'), children: availableLanguages.map((lang) => ((0, jsx_runtime_1.jsx)(select_1.SelectItem, { value: lang, children: LABEL[lang] ?? lang }, lang))) })] })), showCopyButton && ((0, jsx_runtime_1.jsxs)(button_1.Button, { ...(0, slot_1.slot)('code-editor-copy-button'), variant: "ghost", size: "sm", disabled: !text, onClick: copy, children: [copied ? (0, jsx_runtime_1.jsx)(lucide_icons_2_1.Check, { size: 14 }) : (0, jsx_runtime_1.jsx)(lucide_icons_2_1.Copy, { size: 14 }), copied ? 'Copied!' : 'Copy'] }))] })), (0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('code-editor-body'), overflow: "hidden", style: { height }, children: [lineNumbers && ((0, jsx_runtime_1.jsx)(gui_1.YStack, { ...(0, slot_1.slot)('code-editor-gutter'), width: 48, overflow: "hidden", borderRightWidth: 1, borderColor: "$borderColor", bg: "$panel", py: control_1.RISER, style: named && { backgroundColor: named.head, borderColor: named.border }, children: (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$1", fontFamily: "$mono", color: "$soft", px: "$2", whiteSpace: "pre", style: {
180
- fontSize,
181
- lineHeight,
182
- textAlign: 'right',
183
- transform: `translateY(${-scrollTop}px)`,
184
- ...(named && { color: named.gutter }),
185
- }, children: gutter }) })), (0, jsx_runtime_1.jsx)(gui_1.TextArea, { ref: field, ...(0, slot_1.slot)('code-editor-textarea'), "aria-label": label, value: text, onChangeText: change, onKeyDown: key, readOnly: readOnly, unstyled: true, flex: 1, minH: 0, borderWidth: 0, rounded: 0, px: "$2", py: control_1.RISER, fontFamily: "$mono", color: "$color", bg: "transparent", style: {
186
- fontSize,
187
- lineHeight,
188
- whiteSpace: wrap ? 'pre-wrap' : 'pre',
189
- overflowWrap: wrap ? 'break-word' : 'normal',
190
- overflow: 'auto',
191
- resize: 'none',
192
- height: '100%',
193
- ...(named && { color: named.fg }),
194
- } })] })] }));
469
+ // A percentage height fills a frame that is itself sized; the body has to flex for it to
470
+ // resolve. Any other height is the editor's own, so the body is just as tall as the editor.
471
+ const fills = typeof height === 'string' && height.endsWith('%');
472
+ const formattable = showFormatButton ?? (isJson && !readOnly);
473
+ const toolbar = showLanguageSelector || showCopyButton || formattable;
474
+ const footer = resizable || (isJson && check !== null);
475
+ return ((0, jsx_runtime_1.jsxs)(gui_1.YStack, { ...(0, slot_1.slot)('code-editor'), borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", bg: "$background", focusWithinStyle: { borderColor: '$outlineColor' }, ...props, children: [toolbar && ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('code-editor-toolbar'), items: "center", justify: "space-between", flexWrap: "wrap", gap: "$2", borderBottomWidth: 1, borderColor: "$borderColor", bg: "$panel", px: "$2", py: "$1", children: [showLanguageSelector ? ((0, jsx_runtime_1.jsxs)(select_1.Select, { value: selected, onValueChange: setSelected, children: [(0, jsx_runtime_1.jsx)(select_1.SelectTrigger, { ...(0, slot_1.slot)('code-editor-language-trigger'), width: "auto", minHeight: 32, borderWidth: 0, bg: "transparent", gap: "$1.5", px: "$2", children: (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$1", fontFamily: "$mono", children: LABEL[selected] ?? selected }) }), (0, jsx_runtime_1.jsx)(select_1.SelectContent, { ...(0, slot_1.slot)('code-editor-language-content'), children: availableLanguages.map((lang) => ((0, jsx_runtime_1.jsx)(select_1.SelectItem, { value: lang, children: LABEL[lang] ?? lang }, lang))) })] })) : ((0, jsx_runtime_1.jsx)(gui_1.SizableText, { ...(0, slot_1.slot)('code-editor-language'), size: "$1", fontFamily: "$mono", color: "$soft", px: "$2", children: LABEL[selected] ?? selected })), (0, jsx_runtime_1.jsxs)(gui_1.XStack, { items: "center", justify: "flex-end", flexWrap: "wrap", gap: "$1", shrink: 1, minW: 0, children: [formattable && ((0, jsx_runtime_1.jsxs)(button_1.Button, { ...(0, slot_1.slot)('code-editor-format-button'), variant: "ghost", size: "sm", disabled: check?.kind !== 'json', onClick: format, children: [(0, jsx_runtime_1.jsx)(lucide_icons_2_1.AlignLeft, { size: 14 }), "Format"] })), showCopyButton && ((0, jsx_runtime_1.jsxs)(button_1.Button, { ...(0, slot_1.slot)('code-editor-copy-button'), variant: "ghost", size: "sm", disabled: !text, onClick: copy, children: [copied ? (0, jsx_runtime_1.jsx)(lucide_icons_2_1.Check, { size: 14 }) : (0, jsx_runtime_1.jsx)(lucide_icons_2_1.Copy, { size: 14 }), copied ? 'Copied!' : 'Copy'] }))] })] })), (0, jsx_runtime_1.jsx)(gui_1.YStack, { ref: host, ...(0, slot_1.slot)('code-editor-body'), ...(fills && { flex: 1, minH: 0 }) }), footer && ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { ref: grip, ...(0, slot_1.slot)('code-editor-footer'), ...(resizable &&
476
+ {
477
+ role: 'separator',
478
+ 'aria-orientation': 'horizontal',
479
+ 'aria-label': `Resize ${label}`,
480
+ 'aria-valuemin': minHeight,
481
+ 'aria-valuenow': dragged ?? undefined,
482
+ tabIndex: 0,
483
+ }), cursor: resizable ? 'row-resize' : undefined, focusVisibleStyle: { bg: '$hover' }, items: "center", justify: "space-between", gap: "$2", minH: 28, px: "$3", py: "$1", borderTopWidth: 1, borderColor: "$borderColor", bg: "$panel", select: "none", children: [(0, jsx_runtime_1.jsx)(Status, { check: isJson ? check : null }), resizable && (0, jsx_runtime_1.jsx)(lucide_icons_2_1.GripHorizontal, { size: 14, color: "$soft" })] }))] }));
484
+ }
485
+ /** The footer's JSON verdict, announced politely when it changes. */
486
+ function Status({ check }) {
487
+ const bad = check?.kind === 'error';
488
+ const words = check === null
489
+ ? ''
490
+ : check.kind === 'error'
491
+ ? `Line ${check.line}, column ${check.column}: ${check.message}`
492
+ : check.kind === 'text'
493
+ ? 'Plain text'
494
+ : check.kind === 'empty'
495
+ ? 'Empty'
496
+ : 'Valid JSON';
497
+ return ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('code-editor-status'), "aria-live": "polite", items: "center", gap: "$1.5", flex: 1, minW: 0, children: [bad ? ((0, jsx_runtime_1.jsx)(lucide_icons_2_1.CircleAlert, { size: 14, color: "$bad" })) : check?.kind === 'text' ? ((0, jsx_runtime_1.jsx)(lucide_icons_2_1.Type, { size: 14, color: "$soft" })) : check?.kind === 'json' ? ((0, jsx_runtime_1.jsx)(lucide_icons_2_1.CircleCheck, { size: 14, color: "$soft" })) : null, (0, jsx_runtime_1.jsx)(gui_1.SizableText, { ...(0, slot_1.slot)('code-editor-status-text'), "data-kind": check?.kind, size: "$1", fontFamily: "$mono", color: bad ? '$bad' : '$soft', numberOfLines: 2, children: words })] }));
195
498
  }
@@ -1,49 +1,70 @@
1
1
  /**
2
- * CodeEditor — a bordered, monospace editing surface: a toolbar carrying a
3
- * language menu and a copy-to-clipboard button, above a plain-text field with
4
- * a gutter of line numbers that scrolls with it.
2
+ * CodeEditor — a CodeMirror 6 editor in a gui frame: a toolbar with the
3
+ * language (a menu, or a label), Format for JSON and Copy; the editor with a
4
+ * line-number gutter; and a footer that reports whether JSON parses and is the
5
+ * handle that resizes the editor.
5
6
  *
6
- * The field is gui's `TextArea`, so what is edited is the text itself. Tab
7
- * inserts two spaces and Escape releases focus; `wordWrap` is the field's own
8
- * white-space; the gutter numbers lines of text, so a line that wraps keeps
9
- * one number; there is no syntax colouring, and `minimap` is accepted and
10
- * ignored because a plain field has no surface to draw one on.
7
+ * Height. With no `height` the editor is as tall as its text, from `minHeight`
8
+ * up to `maxHeight`, and scrolls past that. Dragging the footer (or ArrowUp /
9
+ * ArrowDown on it) sets a height of the reader's own, past the cap if they
10
+ * want; a double-click hands the height back to the text. A `height` fixes it,
11
+ * and `'100%'` fills a frame that is itself sized (a flex child).
11
12
  *
12
- * `theme` is the editor's own palette, independent of the surrounding gui
13
- * theme the way a syntax theme is: `'light'` and `'dark'` paint a fixed set of
14
- * literal colours, and `'auto'` (the default) takes the ambient `$background`,
15
- * `$color` and `$borderColor` tokens, so it follows the host page.
13
+ * JSON. `language="json"` colours the syntax and checks the text on every
14
+ * change with `checkJson`: a broken document tints the failing line, marks the
15
+ * failing character and names line, column and reason in the footer. With
16
+ * `allowText`, text that does not open an object or an array is plain text and
17
+ * passes. Format (or Shift-Alt-F) re-indents valid JSON by two spaces. Other
18
+ * languages are edited as plain text: the package carries one grammar.
19
+ *
20
+ * Colour. Every colour is a theme rung over a design token — the ink, the
21
+ * hairline, the selection and the error state — so the editor follows the page
22
+ * into light or dark with nothing to configure. Hanzo is monochrome, so the syntax is ranked
23
+ * by ink and weight, not by hue: property names in full ink, strings one rank
24
+ * down, punctuation at the rank of a hint. Hue is spent only on an error.
16
25
  */
17
26
  import { type YStackProps } from '@hanzo/gui';
27
+ import { EditorView } from '@codemirror/view';
18
28
  import * as React from 'react';
19
- export type CodeEditorTheme = 'light' | 'dark' | 'auto';
29
+ import { type JsonCheck } from './json.js';
20
30
  export type CodeEditorWordWrap = 'on' | 'off' | 'wordWrapColumn' | 'bounded';
21
- export interface CodeEditorProps extends Omit<YStackProps, 'children' | 'height' | 'theme' | 'onChange'> {
22
- /** A controlled value; omit it and set `defaultValue` to let the field own its text. */
31
+ export interface CodeEditorProps extends Omit<YStackProps, 'children' | 'height' | 'minHeight' | 'maxHeight' | 'onChange'> {
32
+ /** A controlled value; omit it and set `defaultValue` to let the editor own its text. */
23
33
  value?: string;
24
34
  defaultValue?: string;
25
35
  language?: string;
26
- /** The height of the field; the toolbar sits above it, unsized. */
36
+ /** A fixed height. Omit it and the editor follows its text between `minHeight` and `maxHeight`. */
27
37
  height?: string | number;
28
- theme?: CodeEditorTheme;
38
+ /** The shortest the editor gets when it follows its text, in px. */
39
+ minHeight?: number;
40
+ /** The tallest it grows on its own, in px; past it the editor scrolls. */
41
+ maxHeight?: number;
42
+ /** Whether the footer resizes the editor. Defaults to true unless `height` is set. */
43
+ resizable?: boolean;
44
+ /** JSON only: text that does not open an object or an array is plain text, not an error. */
45
+ allowText?: boolean;
29
46
  onChange?: (value: string) => void;
30
- /** Called once, after mount, with the field's `<textarea>`. */
31
- onMount?: (element: HTMLTextAreaElement) => void;
47
+ /** JSON only: the verdict on the text, after mount and after every edit. */
48
+ onCheck?: (check: JsonCheck) => void;
49
+ /** Called once, after mount, with the CodeMirror view. */
50
+ onMount?: (view: EditorView) => void;
32
51
  readOnly?: boolean;
33
52
  lineNumbers?: boolean;
34
- /** Accepted and ignored: a plain text field has no surface to draw a minimap on. */
35
- minimap?: boolean;
36
53
  wordWrap?: CodeEditorWordWrap;
37
54
  fontSize?: number;
55
+ /** Shown while the editor is empty. */
56
+ placeholder?: string;
38
57
  showCopyButton?: boolean;
58
+ /** Defaults to true for editable JSON. */
59
+ showFormatButton?: boolean;
39
60
  showLanguageSelector?: boolean;
40
61
  availableLanguages?: readonly string[];
41
62
  /**
42
- * The field's accessible name. A text field with none is announced as
43
- * "edit text" with nothing to say what it holds; a file's path is the usual
44
- * answer. Defaults to "Code".
63
+ * The editor's accessible name. An editing surface with none is announced
64
+ * with nothing to say what it holds; a file's path is the usual answer.
65
+ * Defaults to "Code".
45
66
  */
46
67
  label?: string;
47
68
  }
48
- export declare function CodeEditor({ value, defaultValue, language, height, theme, onChange, onMount, readOnly, lineNumbers, minimap: _minimap, wordWrap, fontSize, showCopyButton, showLanguageSelector, availableLanguages, label, ...props }: CodeEditorProps): React.JSX.Element;
69
+ export declare function CodeEditor({ value, defaultValue, language, height, minHeight, maxHeight, resizable, allowText, onChange, onCheck, onMount, readOnly, lineNumbers, wordWrap, fontSize, placeholder, showCopyButton, showFormatButton, showLanguageSelector, availableLanguages, label, ...props }: CodeEditorProps): React.JSX.Element;
49
70
  //# sourceMappingURL=code-editor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"code-editor.d.ts","sourceRoot":"","sources":["../../../src/backends/gui/code-editor.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,YAAY,CAAA;AAEpF,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAQ9B,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAA;AACvD,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,KAAK,GAAG,gBAAgB,GAAG,SAAS,CAAA;AAoE5E,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAAC,WAAW,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAC;IACtG,wFAAwF;IACxF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,KAAK,CAAC,EAAE,eAAe,CAAA;IACvB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,+DAA+D;IAC/D,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAA;IAChD,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,oFAAoF;IACpF,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,QAAQ,CAAC,EAAE,kBAAkB,CAAA;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,YAAiB,EACjB,QAAuB,EACvB,MAAgB,EAChB,KAAc,EACd,QAAQ,EACR,OAAO,EACP,QAAgB,EAChB,WAAkB,EAClB,OAAO,EAAE,QAAQ,EACjB,QAAe,EACf,QAAa,EACb,cAAqB,EACrB,oBAA2B,EAC3B,kBAA8B,EAC9B,KAAc,EACd,GAAG,KAAK,EACT,EAAE,eAAe,qBAsLjB"}
1
+ {"version":3,"file":"code-editor.d.ts","sourceRoot":"","sources":["../../../src/backends/gui/code-editor.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAA+B,KAAK,WAAW,EAAE,MAAM,YAAY,CAAA;AAM1E,OAAO,EAEL,UAAU,EAYX,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAG9B,OAAO,EAAyB,KAAK,SAAS,EAAE,MAAM,QAAQ,CAAA;AAK9D,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,KAAK,GAAG,gBAAgB,GAAG,SAAS,CAAA;AAsN5E,MAAM,WAAW,eACf,SAAQ,IAAI,CAAC,WAAW,EAAE,UAAU,GAAG,QAAQ,GAAG,WAAW,GAAG,WAAW,GAAG,UAAU,CAAC;IACzF,yFAAyF;IACzF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,mGAAmG;IACnG,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAA;IACpC,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAA;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,kBAAkB,CAAA;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,0CAA0C;IAC1C,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,YAAiB,EACjB,QAAuB,EACvB,MAAM,EACN,SAAc,EACd,SAAe,EACf,SAAgC,EAChC,SAAiB,EACjB,QAAQ,EACR,OAAO,EACP,OAAO,EACP,QAAgB,EAChB,WAAkB,EAClB,QAAe,EACf,QAAa,EACb,WAAW,EACX,cAAqB,EACrB,gBAAgB,EAChB,oBAA2B,EAC3B,kBAA8B,EAC9B,KAAc,EACd,GAAG,KAAK,EACT,EAAE,eAAe,qBA2TjB"}