@backmatter/studio 0.0.15 → 0.0.17

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 (80) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-D7vllRQE.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-DH1LD-2Q.js} +30013 -27965
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-Dsaeud2x.js} +55 -54
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-DHJvh5Uy.js → index-CGMqw2NH.js} +1 -1
  6. package/dist/dashboard.js +2 -2
  7. package/dist/define.js +1 -1
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.css +49 -1
  10. package/dist/site-widget.js +8341 -8058
  11. package/dist/studio.css +279 -13
  12. package/package.json +2 -2
  13. package/src/WorkspaceRoutes.tsx +5 -2
  14. package/src/api.ts +82 -7
  15. package/src/chat/ChangeCard.tsx +8 -2
  16. package/src/chat/ChatMessage.tsx +171 -102
  17. package/src/chat/ChatPanel.tsx +54 -34
  18. package/src/chat/ChatProvider.tsx +122 -20
  19. package/src/chat/Composer.tsx +51 -8
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +67 -3
  23. package/src/chat/chat-helpers.ts +12 -0
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +12 -0
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/Toast.tsx +115 -0
  29. package/src/chrome/UserMenu.tsx +34 -3
  30. package/src/chrome/WorkspaceStatusBar.tsx +221 -30
  31. package/src/chrome/commit-error.ts +13 -0
  32. package/src/chrome/content-api-url.ts +0 -78
  33. package/src/chrome/content-tree-visibility.ts +13 -3
  34. package/src/chrome/explorer-icons.tsx +5 -0
  35. package/src/chrome/pull-failure.ts +61 -0
  36. package/src/code-session-entry.ts +9 -0
  37. package/src/components/WorkspaceSwitcher.tsx +2 -2
  38. package/src/element-origin.ts +149 -0
  39. package/src/fields/FieldControl.tsx +748 -0
  40. package/src/fields/choose-field-input.ts +64 -0
  41. package/src/fields/load-relation-documents.ts +35 -0
  42. package/src/fields/omit-field.ts +12 -0
  43. package/src/fields/relation-targets.ts +44 -0
  44. package/src/fields/resolve-object-fields.ts +21 -0
  45. package/src/host-pointer-bootstrap.ts +11 -0
  46. package/src/host-pointer.ts +147 -0
  47. package/src/install-preview-pointer.ts +19 -3
  48. package/src/pages/BlockSettingsDialog.tsx +19 -115
  49. package/src/pages/BlocksList.tsx +43 -35
  50. package/src/pages/BodyProse.tsx +16 -1
  51. package/src/pages/CodeSessionFiles.tsx +221 -0
  52. package/src/pages/CodeSessionPage.tsx +1361 -0
  53. package/src/pages/DocEditorPage.tsx +120 -107
  54. package/src/pages/PreviewSize.tsx +181 -0
  55. package/src/pages/RenderedBody.tsx +17 -2
  56. package/src/pages/SettingsGeneralPage.tsx +67 -2
  57. package/src/pages/WorkspaceHomePage.tsx +313 -0
  58. package/src/pages/WorkspaceLayout.tsx +10 -1
  59. package/src/pages/branch-notice.ts +16 -0
  60. package/src/pages/code-session-boot.ts +43 -0
  61. package/src/pages/code-session-context.tsx +53 -0
  62. package/src/pages/code-session-files.ts +42 -0
  63. package/src/pages/code-session-frame.ts +52 -0
  64. package/src/pages/code-session-routes.ts +42 -0
  65. package/src/pages/home-rows.ts +140 -0
  66. package/src/pages/preview-size.ts +33 -0
  67. package/src/pages/session-preview.ts +109 -0
  68. package/src/pages/use-session-preview.ts +70 -0
  69. package/src/replica/store.ts +11 -0
  70. package/src/replica/types.ts +6 -0
  71. package/src/shell-resets.css +15 -0
  72. package/src/site-pointer.ts +24 -5
  73. package/src/studio-config.ts +1 -1
  74. package/src/ui/Input.tsx +178 -20
  75. package/src/ui/ListRow.tsx +12 -3
  76. package/src/ui/Page.tsx +12 -1
  77. package/src/ui/popup.ts +11 -2
  78. package/src/workspace-path.ts +35 -3
  79. package/src/chrome/ContentApiPopover.tsx +0 -215
  80. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -0,0 +1,64 @@
1
+ // packages/studio/src/fields/choose-field-input.ts
2
+ import type { SchemaField } from "../api";
3
+ import { resolveFieldInput, type StudioDefinition } from "../define-studio";
4
+
5
+ export type FieldInputKind =
6
+ | "custom"
7
+ | "select"
8
+ | "color"
9
+ | "slider"
10
+ | "relation"
11
+ | "object"
12
+ | "array"
13
+ | "media"
14
+ | "boolean"
15
+ | "number"
16
+ | "blocks"
17
+ | "text";
18
+
19
+ export function chooseFieldInput(
20
+ definition: StudioDefinition,
21
+ field: SchemaField,
22
+ models: Array<{ type: string; external?: boolean }>,
23
+ ): FieldInputKind {
24
+ if (resolveFieldInput(definition, field)) {
25
+ return "custom";
26
+ }
27
+
28
+ if (field.kind === "text") {
29
+ if (field.options && field.options.length > 0) {
30
+ return "select";
31
+ }
32
+ if (field.input === "color") {
33
+ return "color";
34
+ }
35
+ }
36
+
37
+ if (field.kind === "number") {
38
+ if (typeof field.min === "number" && typeof field.max === "number") {
39
+ return "slider";
40
+ }
41
+ }
42
+
43
+ if (field.kind === "relation") {
44
+ const target = models.find((m) => m.type === field.relationTo);
45
+ if (target && !target.external) {
46
+ return "relation";
47
+ }
48
+ return "text";
49
+ }
50
+
51
+ if (field.kind === "object") {
52
+ return "object";
53
+ }
54
+ if (field.kind === "array") {
55
+ return "array";
56
+ }
57
+
58
+ if (field.kind === "media") return "media";
59
+ if (field.kind === "boolean") return "boolean";
60
+ if (field.kind === "number") return "number";
61
+ if (field.kind === "blocks") return "blocks";
62
+
63
+ return "text";
64
+ }
@@ -0,0 +1,35 @@
1
+ // packages/studio/src/fields/load-relation-documents.ts
2
+ type DocListRow = { type: string; slug: string; status: string };
3
+ type DocEntry = { slug: string; frontmatter: Record<string, unknown> };
4
+
5
+ export async function loadRelationDocuments(
6
+ types: string[],
7
+ list: () => Promise<DocListRow[]>,
8
+ read: (type: string, slug: string) => Promise<{ frontmatter: Record<string, unknown> }>,
9
+ ): Promise<Record<string, DocEntry[]>> {
10
+ const wanted = new Set(types);
11
+ const grouped: Record<string, DocEntry[]> = Object.fromEntries(types.map((type) => [type, []]));
12
+ let rows: DocListRow[];
13
+ try {
14
+ rows = await list();
15
+ } catch {
16
+ return grouped;
17
+ }
18
+
19
+ const selected = rows.filter((row) => wanted.has(row.type) && row.status !== "deleted");
20
+ const loaded = await Promise.all(
21
+ selected.map(async (row) => {
22
+ try {
23
+ const { frontmatter } = await read(row.type, row.slug);
24
+ return { type: row.type, entry: { slug: row.slug, frontmatter } satisfies DocEntry };
25
+ } catch {
26
+ return null;
27
+ }
28
+ }),
29
+ );
30
+ for (const item of loaded) {
31
+ if (!item) continue;
32
+ grouped[item.type]!.push(item.entry);
33
+ }
34
+ return grouped;
35
+ }
@@ -0,0 +1,12 @@
1
+ // packages/studio/src/fields/omit-field.ts
2
+ export function omitField(
3
+ record: Record<string, unknown>,
4
+ name: string,
5
+ next: unknown,
6
+ ): Record<string, unknown> {
7
+ if (next === undefined) {
8
+ const { [name]: _removed, ...rest } = record;
9
+ return rest;
10
+ }
11
+ return { ...record, [name]: next };
12
+ }
@@ -0,0 +1,44 @@
1
+ // packages/studio/src/fields/relation-targets.ts
2
+ import type { SchemaField } from "../api";
3
+ import { resolveObjectFields } from "./resolve-object-fields";
4
+
5
+ type ModelRef = { type: string; external?: boolean };
6
+ type ObjectDef = { name: string; fields: Record<string, SchemaField> };
7
+
8
+ function collectFromFields(
9
+ fields: Record<string, SchemaField>,
10
+ models: ModelRef[],
11
+ objects: ObjectDef[],
12
+ visiting: Set<string>,
13
+ targets: Set<string>,
14
+ ): void {
15
+ for (const field of Object.values(fields)) {
16
+ if (field.kind === "relation" && field.relationTo) {
17
+ const target = models.find((model) => model.type === field.relationTo);
18
+ if (target && !target.external) {
19
+ targets.add(field.relationTo);
20
+ }
21
+ }
22
+
23
+ const nested = resolveObjectFields(field, objects);
24
+ if (!nested) continue;
25
+
26
+ let nextVisiting = visiting;
27
+ if (field.object && (field.kind === "object" || field.kind === "array")) {
28
+ if (visiting.has(field.object)) continue;
29
+ nextVisiting = new Set(visiting).add(field.object);
30
+ }
31
+
32
+ collectFromFields(nested, models, objects, nextVisiting, targets);
33
+ }
34
+ }
35
+
36
+ export function relationTargets(
37
+ fields: Record<string, SchemaField>,
38
+ models: ModelRef[],
39
+ objects: ObjectDef[],
40
+ ): string[] {
41
+ const targets = new Set<string>();
42
+ collectFromFields(fields, models, objects, new Set<string>(), targets);
43
+ return [...targets].sort();
44
+ }
@@ -0,0 +1,21 @@
1
+ // packages/studio/src/fields/resolve-object-fields.ts
2
+ import type { SchemaField } from "../api";
3
+
4
+ export function resolveObjectFields(
5
+ field: SchemaField,
6
+ objects: Array<{ name: string; fields: Record<string, SchemaField> }>,
7
+ ): Record<string, SchemaField> | null {
8
+ if (field.fields) {
9
+ return field.fields;
10
+ }
11
+ if (field.object) {
12
+ const entry = objects.find((object) => object.name === field.object);
13
+ return entry?.fields ?? null;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function nestedStartsCollapsed(ancestorObjectNames: string[], objectName: string | undefined): boolean {
19
+ if (!objectName) return false;
20
+ return ancestorObjectNames.includes(objectName);
21
+ }
@@ -0,0 +1,11 @@
1
+ // packages/studio/src/host-pointer-bootstrap.ts
2
+ // Built into the script the code session sandbox injects. The source of truth
3
+ // is bindHostPointer, the same pointer the document editor's preview uses.
4
+ import { bindHostPointer } from "./host-pointer";
5
+
6
+ function start() {
7
+ bindHostPointer(document);
8
+ }
9
+
10
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
11
+ else start();
@@ -0,0 +1,147 @@
1
+ // packages/studio/src/host-pointer.ts
2
+ //
3
+ // Page side of the preview channel for a framed host that is not running the
4
+ // preview client. The document editor's preview page uses the same pointer and
5
+ // the same messages (hello, point, grab, highlight). This does not accept a
6
+ // document push.
7
+ import {
8
+ createWindowPreviewTransport,
9
+ samePreviewRef,
10
+ type PreviewChannelRef,
11
+ type PreviewWindowLike,
12
+ } from "@backmatter/core/preview";
13
+ import { installPreviewPointer } from "./install-preview-pointer";
14
+ import { findPointedElement, highlightPointedElement } from "./site-pointer";
15
+
16
+ const POINTER_STYLE = "html.backmatter-pointing, html.backmatter-pointing * { cursor: crosshair !important; }";
17
+
18
+ function parentOrigin(view: Window): string {
19
+ try {
20
+ if (view.document.referrer) return new URL(view.document.referrer).origin;
21
+ } catch {
22
+ // A malformed referrer means we cannot name the framing Studio.
23
+ }
24
+ const ancestors = (view.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;
25
+ if (ancestors && ancestors.length > 0) return ancestors.item(0) ?? "";
26
+ return "";
27
+ }
28
+
29
+ /** The document the page announces. A missing marker falls back to the route. */
30
+ export function hostPointerRef(doc: Document): PreviewChannelRef {
31
+ const marker = doc.querySelector("[data-backmatter-doc]");
32
+ const type = marker?.getAttribute("data-type");
33
+ const slug = marker?.getAttribute("data-slug");
34
+ if (type && slug) return { type, slug };
35
+ const path = doc.defaultView?.location.pathname || "/";
36
+ return { type: "route", slug: path };
37
+ }
38
+
39
+ /**
40
+ * Tell the framing Studio this page can be pointed at, and arm when it asks.
41
+ * No-ops on a top-level page and on a page that already runs the preview client.
42
+ */
43
+ export function bindHostPointer(doc: Document): () => void {
44
+ const view = doc.defaultView;
45
+ if (!view || view.parent === view) return () => {};
46
+ if (doc.querySelector("[data-backmatter-preview]")) return () => {};
47
+ const flagged = view as Window & { __backmatterHostPointer?: boolean };
48
+ if (flagged.__backmatterHostPointer) return () => {};
49
+
50
+ const origin = parentOrigin(view);
51
+ if (!origin) return () => {};
52
+ flagged.__backmatterHostPointer = true;
53
+
54
+ const ref = hostPointerRef(doc);
55
+ if (!doc.querySelector("[data-backmatter-pointer-style]")) {
56
+ const style = doc.createElement("style");
57
+ style.setAttribute("data-backmatter-pointer-style", "");
58
+ style.textContent = POINTER_STYLE;
59
+ doc.head.appendChild(style);
60
+ }
61
+
62
+ const transport = createWindowPreviewTransport({
63
+ target: view.parent,
64
+ targetOrigin: origin,
65
+ allowedOrigins: [origin],
66
+ host: view as unknown as PreviewWindowLike,
67
+ });
68
+ function sendGrab(grab: {
69
+ blockId: string | null;
70
+ blockType: string | null;
71
+ blockIndex: number | null;
72
+ quote: string;
73
+ shift: boolean;
74
+ component?: string | null;
75
+ file?: string | null;
76
+ line?: number | null;
77
+ column?: number | null;
78
+ stack?: string[];
79
+ }) {
80
+ transport.send({
81
+ type: "backmatter:grab",
82
+ v: 1,
83
+ ref,
84
+ blockId: grab.blockId,
85
+ blockType: grab.blockType,
86
+ blockIndex: grab.blockIndex,
87
+ quote: grab.quote,
88
+ shift: grab.shift,
89
+ ...(grab.component ? { component: grab.component } : {}),
90
+ ...(grab.file ? { file: grab.file } : {}),
91
+ ...(grab.line ? { line: grab.line } : {}),
92
+ ...(grab.column ? { column: grab.column } : {}),
93
+ ...(grab.stack && grab.stack.length > 0 ? { stack: grab.stack } : {}),
94
+ });
95
+ }
96
+ const pointer = installPreviewPointer(doc, {
97
+ bar: () => doc.querySelector("[data-backmatter-devbar]"),
98
+ includeUnmarked: true,
99
+ ignore: (target) => {
100
+ if (!(target instanceof view.Element)) return true;
101
+ if (target.closest("[data-backmatter-panel]")) return true;
102
+ return target.closest("iframe") !== null;
103
+ },
104
+ onEditorGrab: sendGrab,
105
+ onUnmarkedGrab: sendGrab,
106
+ onDevbarGrab: () => {},
107
+ onEscape: (source) => {
108
+ if (source !== "editor") return;
109
+ transport.send({ type: "backmatter:point", v: 1, ref, on: false });
110
+ },
111
+ });
112
+ const unsubscribe = transport.subscribe((message) => {
113
+ if (message.type === "backmatter:hotkey" || message.type === "backmatter:hello") return;
114
+ if (!("ref" in message)) return;
115
+ // Studio binds after the first hello on a reload. A message for another
116
+ // document is the cue to announce this page again.
117
+ if (!samePreviewRef(message.ref, ref)) {
118
+ if (message.type === "backmatter:point" || message.type === "backmatter:highlight") hello();
119
+ return;
120
+ }
121
+ if (message.type === "backmatter:point") {
122
+ if (message.on) pointer.arm("editor");
123
+ else if (pointer.source() === "editor") pointer.disarm();
124
+ return;
125
+ }
126
+ if (message.type === "backmatter:highlight") {
127
+ const target = message.blockId
128
+ ? findPointedElement(doc, { blockId: message.blockId, quote: "" })
129
+ : null;
130
+ highlightPointedElement(doc, target);
131
+ }
132
+ });
133
+
134
+ function hello() {
135
+ transport.send({ type: "backmatter:hello", v: 1, ref, capabilities: ["point"] });
136
+ }
137
+ hello();
138
+ const retries = [100, 500].map((ms) => view.setTimeout(hello, ms));
139
+
140
+ return () => {
141
+ for (const id of retries) view.clearTimeout(id);
142
+ unsubscribe();
143
+ pointer.dispose();
144
+ transport.close();
145
+ flagged.__backmatterHostPointer = false;
146
+ };
147
+ }
@@ -8,12 +8,25 @@ import {
8
8
  type PointerGrab,
9
9
  } from "./site-pointer";
10
10
 
11
+ function placeOf(grab: PointerGrab) {
12
+ return {
13
+ ...(grab.component ? { component: grab.component } : {}),
14
+ ...(grab.file ? { file: grab.file } : {}),
15
+ ...(grab.line ? { line: grab.line } : {}),
16
+ ...(grab.column ? { column: grab.column } : {}),
17
+ ...(grab.stack.length > 0 ? { stack: grab.stack } : {}),
18
+ };
19
+ }
20
+
11
21
  export function installPreviewPointer(
12
22
  doc: Document,
13
23
  options: {
14
24
  bar: () => Element | null;
15
25
  ignore?: (target: EventTarget | null) => boolean;
16
- onEditorGrab: (grab: RoutedGrab) => void;
26
+ /** Armed clicks that are not page blocks. The document editor leaves this off. */
27
+ includeUnmarked?: boolean;
28
+ onEditorGrab: (grab: RoutedGrab & ReturnType<typeof placeOf>) => void;
29
+ onUnmarkedGrab?: (grab: PointerGrab & { shift: boolean }) => void;
17
30
  onDevbarGrab: (grab: PointerGrab) => void;
18
31
  onEscape?: (source: PointerArmSource) => void;
19
32
  },
@@ -27,8 +40,11 @@ export function installPreviewPointer(
27
40
 
28
41
  function deliver(grab: PointerGrab, shift: boolean) {
29
42
  const route = routePointerHit(source, { ...grab, shift });
30
- if (route.to === "editor") options.onEditorGrab(route.grab);
31
- else if (route.to === "devbar") options.onDevbarGrab(route.grab);
43
+ if (route.to === "editor") options.onEditorGrab({ ...route.grab, ...placeOf(grab) });
44
+ else if (route.to === "devbar") options.onDevbarGrab(grab);
45
+ else if (options.includeUnmarked && source === "editor" && (grab.quote || grab.component || grab.file)) {
46
+ options.onUnmarkedGrab?.({ ...grab, shift });
47
+ }
32
48
  }
33
49
 
34
50
  const pointer = installSitePointer(doc, {
@@ -3,30 +3,32 @@ import { useState } from "react";
3
3
  import { Dialog } from "@base-ui/react/dialog";
4
4
  import * as stylex from "@stylexjs/stylex";
5
5
  import type { SchemaField } from "../api";
6
- import { useFieldInput } from "../define-studio";
6
+ import { FieldControl } from "../fields/FieldControl";
7
+ import { omitField } from "../fields/omit-field";
7
8
  import { colors, radius, space, type, z } from "../styles/tokens.stylex";
8
9
  import { Button } from "../ui/Button";
9
10
  import { Field } from "../ui/Field";
10
- import { Input, Textarea } from "../ui/Input";
11
11
  import { cx, isTransitioning } from "../ui/popup";
12
- import { MediaFieldInput } from "./MediaFieldInput";
12
+
13
+ type ModelRef = { type: string; external?: boolean; fields?: Record<string, SchemaField> };
14
+ type RelationDocumentLists = Record<
15
+ string,
16
+ Array<{ slug: string; frontmatter: Record<string, unknown> }>
17
+ >;
13
18
 
14
19
  function fieldLabel(name: string, field: SchemaField) {
15
20
  if ("label" in field && typeof field.label === "string" && field.label) return field.label;
16
21
  return name.charAt(0).toUpperCase() + name.slice(1);
17
22
  }
18
23
 
19
- function compositeText(value: unknown): string {
20
- if (value == null || value === "") return "";
21
- if (typeof value === "string") return value;
22
- return JSON.stringify(value, null, 2);
23
- }
24
-
25
24
  export function BlockSettingsDialog({
26
25
  name,
27
26
  title,
28
27
  fields,
29
28
  value,
29
+ models,
30
+ objects = [],
31
+ documentLists,
30
32
  readOnly,
31
33
  onOpenChange,
32
34
  onSave,
@@ -36,32 +38,19 @@ export function BlockSettingsDialog({
36
38
  title?: string;
37
39
  fields: Record<string, SchemaField>;
38
40
  value: Record<string, unknown>;
41
+ models: ModelRef[];
42
+ objects?: Array<{ name: string; fields: Record<string, SchemaField> }>;
43
+ documentLists?: RelationDocumentLists;
39
44
  readOnly?: boolean;
40
45
  onOpenChange: (open: boolean) => void;
41
46
  onSave: (props: Record<string, unknown>) => void;
42
47
  onRemove: () => void;
43
48
  }) {
44
49
  const [draft, setDraft] = useState(value);
45
- const [error, setError] = useState<string | null>(null);
46
50
  const names = Object.keys(fields);
47
51
 
48
52
  function save() {
49
- const next = { ...draft };
50
- for (const [fieldName, field] of Object.entries(fields)) {
51
- if (field.kind !== "object" && field.kind !== "array") continue;
52
- const raw = next[fieldName];
53
- if (typeof raw !== "string" || raw.trim() === "") {
54
- next[fieldName] = raw === "" ? "" : raw;
55
- continue;
56
- }
57
- try {
58
- next[fieldName] = JSON.parse(raw);
59
- } catch {
60
- setError(`${fieldLabel(fieldName, field)} needs valid JSON.`);
61
- return;
62
- }
63
- }
64
- onSave(next);
53
+ onSave(draft);
65
54
  }
66
55
 
67
56
  return (
@@ -100,17 +89,18 @@ export function BlockSettingsDialog({
100
89
  name={fieldName}
101
90
  field={field}
102
91
  value={draft[fieldName]}
92
+ models={models}
93
+ objects={objects}
94
+ documentLists={documentLists}
103
95
  readOnly={readOnly}
104
96
  onChange={(next) => {
105
- setError(null);
106
- setDraft((current) => ({ ...current, [fieldName]: next }));
97
+ setDraft((current) => omitField(current, fieldName, next));
107
98
  }}
108
99
  />
109
100
  </Field>
110
101
  );
111
102
  })
112
103
  )}
113
- {error ? <p {...stylex.props(styles.error)}>{error}</p> : null}
114
104
  <div {...stylex.props(styles.actions)}>
115
105
  {readOnly ? null : (
116
106
  <Button variant="danger" onClick={onRemove}>
@@ -137,82 +127,6 @@ export function BlockSettingsDialog({
137
127
  );
138
128
  }
139
129
 
140
- function FieldControl({
141
- name,
142
- field,
143
- value,
144
- readOnly,
145
- onChange,
146
- }: {
147
- name: string;
148
- field: SchemaField;
149
- value: unknown;
150
- readOnly?: boolean;
151
- onChange: (value: unknown) => void;
152
- }) {
153
- const CustomInput = useFieldInput(field);
154
- if (CustomInput) {
155
- return (
156
- <CustomInput name={name} field={field} value={value} readOnly={readOnly} onChange={onChange} />
157
- );
158
- }
159
- if (field.kind === "media") {
160
- return <MediaFieldInput name={name} value={value} readOnly={readOnly} onChange={onChange} />;
161
- }
162
- if (field.kind === "boolean") {
163
- return (
164
- <input
165
- type="checkbox"
166
- checked={value === true}
167
- disabled={readOnly}
168
- onChange={(event) => onChange(event.target.checked)}
169
- {...stylex.props(styles.check)}
170
- />
171
- );
172
- }
173
- if (field.kind === "number") {
174
- return (
175
- <Input
176
- type="number"
177
- value={value == null || value === "" ? "" : String(value)}
178
- readOnly={readOnly}
179
- onChange={(event) => {
180
- const raw = event.target.value;
181
- onChange(raw === "" ? "" : Number(raw));
182
- }}
183
- />
184
- );
185
- }
186
- if ("multiline" in field && field.multiline) {
187
- return (
188
- <Textarea
189
- rows={4}
190
- value={value == null ? "" : String(value)}
191
- readOnly={readOnly}
192
- onChange={(event) => onChange(event.target.value)}
193
- />
194
- );
195
- }
196
- if (field.kind === "object" || field.kind === "array") {
197
- return (
198
- <Textarea
199
- rows={4}
200
- value={compositeText(value)}
201
- readOnly={readOnly}
202
- spellCheck={false}
203
- onChange={(event) => onChange(event.target.value)}
204
- />
205
- );
206
- }
207
- return (
208
- <Input
209
- value={value == null ? "" : String(value)}
210
- readOnly={readOnly}
211
- onChange={(event) => onChange(event.target.value)}
212
- />
213
- );
214
- }
215
-
216
130
  const styles = stylex.create({
217
131
  backdrop: {
218
132
  position: "fixed",
@@ -282,16 +196,6 @@ const styles = stylex.create({
282
196
  color: colors.textMuted,
283
197
  fontSize: type.sm,
284
198
  },
285
- error: {
286
- margin: 0,
287
- color: colors.danger,
288
- fontSize: type.sm,
289
- },
290
- check: {
291
- width: 16,
292
- height: 16,
293
- margin: 0,
294
- },
295
199
  actions: {
296
200
  display: "flex",
297
201
  alignItems: "center",