From 36c27e5c6cd500d0152501a934bb52b257ad1a7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:11:33 +0000 Subject: [PATCH 1/4] fix(editor): keep block values across pages, fit around panels, HTML blocks with CSS and JS variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blocks start from their saved values instead of their defaults. Going to the block editor and back remounted every block with its defaults, which were written over the saved values (position, size, text, code) before being read back; anything reading the store then (autosave, the shared selection box) could keep them. It also made a new code block show raw `
` markup for a frame, and a code block lost a custom
  background because its theme effect ran again on mount.
- Content-sized blocks no longer store a 0 × 0 size while the canvas is
  hidden during navigation.
- A text block exported while selected or hovered no longer carries its
  blue outline into the image.
- Zoom to fit and Zoom to 100% fit and center the canvas in the space the
  floating toolbar and properties panel leave free (with Agent open the
  canvas ended up under the properties panel).
- HTML block scripts can build elements: the scoped `document` called page
  methods on the proxy, so `createElement` threw and lists rendered from a
  JS array stayed empty. One shared `createScopedDocument` replaces the two
  copies. A `// @var` value followed by a semicolon now parses.
- Agent and MCP: every HTML block has html, css and js, with annotated
  `:root` variables and `// @var` JS variables (html-contract.ts).
  add_block, update_block and update_html_block refuse other code with what
  to fix (also a redeclared variable or a list that is not a JSON list of
  strings) and turn allow-scripts on. The guide, prompts, catalog and docs
  say so.
- Property panel of HTML blocks: readable variable names in aligned rows,
  numbers with a typed value, lists edited item by item (reorder, remove,
  add; numbers no longer break editing), objects edited in place or as
  JSON, and a compact shadow editor.

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01PQYEWDFg3DVAHPn4MuxJQS
---
 AGENTS.md                                     |   1 +
 CHANGELOG.md                                  |   7 +
 docs/agent.md                                 |  22 +-
 src/components/Blocks/CodeBlock.tsx           |  50 +-
 src/components/Blocks/ControlTemplate.tsx     |   4 +-
 src/components/Blocks/HTMLBlock.tsx           |  37 +-
 .../Blocks/HTMLBlockBindingsPanel.tsx         | 408 +++++++++--------
 src/components/Blocks/TextBlock.tsx           |  13 +-
 src/components/CustomControls/ArrayEditor.tsx | 284 +++++-------
 src/components/CustomControls/DraftInput.tsx  |  55 +++
 .../CustomControls/ObjectEditor.tsx           | 433 +++++++-----------
 .../CustomControls/ShadowEditor.tsx           | 256 +++++------
 src/components/Panels/LeftPanel.tsx           |   7 +-
 src/components/Panels/RightPanel.tsx          |   2 +
 src/hooks/useControlState.test.tsx            | 105 +++++
 src/hooks/useControlState.ts                  |  54 +--
 src/lib/agent/core/design-guide.ts            |  31 +-
 src/lib/agent/core/system-prompt.ts           |   3 +-
 src/lib/agent/mcp/instructions.ts             |   3 +-
 src/lib/agent/tools/blocks.ts                 |  47 +-
 src/lib/agent/tools/html-contract.test.ts     |  74 +++
 src/lib/agent/tools/html-contract.ts          | 116 +++++
 src/lib/agent/tools/html-hints.ts             |   6 +-
 src/lib/agent/tools/tools.test.ts             |  42 +-
 src/lib/blocks-api/index.ts                   |   1 +
 src/lib/blocks-api/javascript-parser.test.ts  |  11 +
 src/lib/blocks-api/javascript-parser.ts       |   4 +-
 src/lib/blocks-api/scoped-document.test.ts    |  36 ++
 src/lib/blocks-api/scoped-document.ts         |  43 ++
 src/lib/blocks/catalog.ts                     |   2 +-
 src/lib/viewer.test.ts                        |  48 ++
 src/lib/viewer.ts                             |  97 +++-
 src/pages/BlockEditor.tsx                     |  37 +-
 src/pages/Editor.tsx                          |  12 +-
 src/utils/PrismThemes.tsx                     |  10 +
 35 files changed, 1409 insertions(+), 952 deletions(-)
 create mode 100644 src/components/CustomControls/DraftInput.tsx
 create mode 100644 src/hooks/useControlState.test.tsx
 create mode 100644 src/lib/agent/tools/html-contract.test.ts
 create mode 100644 src/lib/agent/tools/html-contract.ts
 create mode 100644 src/lib/blocks-api/scoped-document.test.ts
 create mode 100644 src/lib/blocks-api/scoped-document.ts

diff --git a/AGENTS.md b/AGENTS.md
index e6ae6885..f1349fcf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -110,6 +110,7 @@ Agent (assistant panel, `Mod+L`) and the MCP server share one set of tools. Full
 - **History**: batch entries can mix property changes, `workspace-structure-*` snapshots and `workspace-settings-*` snapshots. Apply undo/redo with `undo()`/`redo()` from `src/lib/editor/history.ts`, which handles all of them.
 - **Block catalog** (`src/lib/blocks/catalog.ts`): mirrors the `useControlState` keys and defaults of each block. Update it when a block gains or renames a property.
 - **Component library** (`src/lib/agent/tools/components.ts`): `list_components`, `import_component`, `add_component`, `export_component` and `load_starter_pack` read and write the `.kcomponent` library (`src/stores/kcomponent-store.ts`, persisted) and put components on the canvas through `addBlock`. Adding one goes through `kcomponentBlockInput()` so an empty `css`/`js` section does not fall back to the demo content of a blank HTML block.
+- **HTML block contract** (`src/lib/agent/tools/html-contract.ts`): HTML blocks written through `add_block`, `update_block` or `update_html_block` must have annotated `:root` CSS variables and `// @var` JS variables; the tools refuse other code with what to fix and turn `allow-scripts` on. `html-hints.ts` adds softer hints (too few variables, too big, too much text).
 - **Command allowlist** (`src/lib/agent/tools/commands.ts`): `run_command` only runs `edit.*`, `arrange.*`, `view.*` and individually vetted ids. `tools.*` stays out on purpose (a model cannot drag on the canvas, so picking a tool would only strand the editor in a mode); dialogs, saving and `workspace.clean` stay out too. Widen it there, with the reason in the comment.
 - **Providers** (`src/lib/agent/providers/`): pure adapters (Anthropic Messages, OpenAI Chat Completions, Gemini) that build requests and parse SSE streams; presets add base URLs and hints. The UI never depends on the provider.
 - **Transports**: the web uses `fetch` from the page (the provider must allow CORS). Electron sends requests from the main process (`src-electron/agent/http.ts`), which adds the API key; keys are encrypted with `safeStorage` and bound to the origin they were saved for. Never log requests, headers or keys; use `redactSecrets()` for error text.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e69aec55..9279b6d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,8 @@
 - **Agent and MCP clients build designs block by block**: the design guide now says what each block is for, asks for text blocks for the copy and one HTML block per component, and gives proven layouts with an example plan. HTML blocks must declare their colors, sizes, radius, shadow, icon and show/hide flags as annotated `:root` variables, with a worked example; `add_block` and `update_html_block` answer with hints when a block misses them or grows into a whole section
 - **Agent and MCP clients choose fonts on purpose**: a new `search_fonts` tool browses the whole Google Fonts catalog by name, kind and weight; the design guide gives pairings by tone and the tools warn when a family will not load or lacks the weight asked for
 - **Editable data in HTML blocks**: lists, chart data and rows go in `// @var` JS variables that the block renders, so they are edited from the panel; CSS variables are derived from each other (`color-mix`, `calc`) so one change restyles the whole component
+- **Every HTML block Agent and MCP clients write has CSS and JS variables**: the look as annotated `:root` variables and the content (labels, values, lists) as `// @var` JS variables that the script renders, so both are edited from the panel. `add_block`, `update_block` and `update_html_block` refuse a block without them, with a script that declares a variable twice or with a list that is not a JSON list of strings, say what to fix and turn scripts on for the block
+- **Easier property panel for HTML blocks**: variables read as names ("Value size", not `--value-size`) in rows that line up with the other panels; numbers take a typed value next to the slider, lists edit each item in its own field (Enter applies, reorder, remove, add), objects edit each value in place or as JSON, and the shadow editor is a compact set of rows
 - **Agent and MCP clients look at their work**: they check the canvas with `get_canvas_snapshot` while they build and after each change, a reminder comes after several changes without a look, and the snapshot waits for fonts to load
 - **Vector brush** (`B`): draw freehand on the canvas and the stroke is kept as a curve, not as pixels. The points are smoothed and thinned into a bezier path, so it stays sharp at any size; the bar at the bottom sets width, smoothing, thinning and color, and each stroke becomes a block that can be moved, resized, rotated, recolored, closed and filled, and undone
 - **Eraser** (`E`): press and go over what you want gone; everything the pointer touches in one pass is deleted in one step
@@ -103,6 +105,11 @@
 - CSS variables without a `@type` annotation were always detected as text, so colors, sizes and booleans got a text field instead of a color picker, slider or switch
 - HTML block scripts broke when a JS string variable contained quotes or new lines, or when an action label contained an apostrophe
 - Redo after undoing several steps restored the wrong values
+- Going to the block editor and back (or anything else that shows the canvas again) could lose block positions and other values: every block painted its defaults for a frame and wrote them over its saved values before reading them back. Blocks now start from their saved values, and a custom background of a code block survives
+- The first render of a code block showed raw `
` markup before the code
+- A text block exported while selected (or under the pointer) kept its blue outline in the image
+- **Zoom to fit** and **Zoom to 100%** ignored the panels floating over the canvas: with Agent open the canvas ended up under the properties panel. They now fit and center it in the space left free
+- HTML block scripts could not build elements: `document.createElement` threw "Illegal invocation", so lists rendered from a JS array stayed empty. Editing a list whose items were numbers did nothing, and a `// @var` value followed by a semicolon read as empty
 
 ### ⚡ Performance
 
diff --git a/docs/agent.md b/docs/agent.md
index b4a7995d..082b0ea0 100644
--- a/docs/agent.md
+++ b/docs/agent.md
@@ -110,14 +110,20 @@ Change the guide there; the prompt, the instructions and the tool follow.
 The guide asks models to build a design block by block: the background with
 `set_canvas_background`, every headline and paragraph as a text block, and one
 HTML block per component (a stat tile, a card, a badge row, a chart), sized to
-its content and declaring its colors, sizes, radius, shadow and icon as
-annotated `:root` variables. `add_block` and `update_html_block` check the
-last two rules (`src/lib/agent/tools/html-hints.ts`) and answer with `hints`
-when an HTML block declares no variables, covers most of the canvas or holds
-paragraphs of text, so the model fixes it in the same turn. Content that
-repeats (list items, chart data, rows) goes in `// @var` JS variables that the
-block script renders, with `allow-scripts` on, so the user edits the data from
-the panel; the hints flag a block whose script cannot run.
+its content.
+
+Every HTML block a model writes has html, css and js
+(`src/lib/agent/tools/html-contract.ts`): the look as annotated `:root`
+variables in the CSS and the content (labels, values, list items, chart data)
+as `// @var` JS variables that the script writes into the markup, so the user
+edits both from the panel. `add_block`, `update_block` and `update_html_block`
+refuse code without them, with a script that declares a `// @var` again, or
+with an array or object value that is not JSON (an array must be a list of
+strings, which is what the panel edits), and say what to fix; they turn
+`allow-scripts` on for the block. They also answer with `hints`
+(`src/lib/agent/tools/html-hints.ts`) when an HTML block has too few
+variables, covers most of the canvas or holds paragraphs of text, so the model
+fixes it in the same turn.
 
 Fonts: the guide sends models to `search_fonts` (the whole Google Fonts
 catalog, with the weights of each family), gives pairings by tone and asks for
diff --git a/src/components/Blocks/CodeBlock.tsx b/src/components/Blocks/CodeBlock.tsx
index 768bbb32..7b4d061e 100644
--- a/src/components/Blocks/CodeBlock.tsx
+++ b/src/components/Blocks/CodeBlock.tsx
@@ -15,7 +15,7 @@ import {
 	IconTerminal,
 	IconX,
 } from '@tabler/icons-react';
-import React, { useEffect } from 'react';
+import React, { useEffect, useRef } from 'react';
 import { PropertyRow, SliderField } from '../CustomControls/PropertyControls';
 import { Switch } from '../ui/switch';
 import { ControlTemplate } from './ControlTemplate';
@@ -25,8 +25,9 @@ import { ColorPicker } from '../CustomControls/ColorPicker';
 import { CloseSvg, MinimizeSvg } from '../Misc/Icons';
 import { LanguajeTabIcon } from './LanguajeTabIcon';
 import { useControlState } from '../../hooks/useControlState';
+import { useControlsStore, useWorkspaceStore } from '@/stores';
 import { useResolvedText } from '../../hooks/useProjectVariables';
-import { themes } from '../../utils/PrismThemes';
+import { codeThemeBackground, themes } from '../../utils/PrismThemes';
 import { Label } from '../ui/label';
 import { Input } from '../ui/input';
 import {
@@ -46,11 +47,12 @@ const CodeControl: React.FC = ({ id }) => {
 
 	const [theme, setTheme] = useControlState('coldarkDark', `${id}-theme`);
 	const [language, setLanguage] = useControlState('jsx', `${id}-lang`);
-	const [code, setCode] = useControlState(
-		`
`, - `${id}-code`, + const [code, setCode] = useControlState('', `${id}-code`); + // A new block starts on the background of its theme. + const [color, setColor] = useControlState( + codeThemeBackground(theme) ?? '#111b28', + `${id}-bgcolor`, ); - const [color, setColor] = useControlState('#111b28', `${id}-bgcolor`); const [controlsColor, setControlsColor] = useControlState( '#b4b4b4', `${id}-ccolor`, @@ -86,18 +88,40 @@ const CodeControl: React.FC = ({ id }) => { return themes.find((value) => value.label === theme)?.theme; }; - /* Handle Change Theme Colors */ + /* A new block keeps the background it starts on, so Agent and saved + projects read the color it shows rather than the catalog default. */ useEffect(() => { - const newTheme = themes.find((value) => value.label === theme)?.theme; - - setColor( - (newTheme as any)[':not(pre) > code[class*="language-"]'].background || - (newTheme as any)[`code[class*="language-"]`].background, + const key = `${id}-bgcolor`; + const { ControlProperties, initialProperties, addControlProperty } = + useControlsStore.getState(); + const known = [...ControlProperties, ...initialProperties].some( + (item) => item.id === key, ); + if (!known) { + addControlProperty( + { id: key, value: color }, + useWorkspaceStore.getState().currentWorkspaceID, + ); + } + // Only the color the block mounted with. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + /* Picking a theme or the paper style resets the background to theme it. + Only when they change: mounting again (after the block editor, a + workspace switch, a project load) keeps the background the user chose. */ + const shownTheme = useRef(theme); + useEffect(() => { + if (shownTheme.current === theme) return; + shownTheme.current = theme; + const background = codeThemeBackground(theme); + if (background) setColor(background); }, [theme]); - /* Handle Window Style Change - Set color for paper style */ + const shownWindowStyle = useRef(windowStyle); useEffect(() => { + if (shownWindowStyle.current === windowStyle) return; + shownWindowStyle.current = windowStyle; if (windowStyle === 'paper') { setColor('#fbfaf7'); } diff --git a/src/components/Blocks/ControlTemplate.tsx b/src/components/Blocks/ControlTemplate.tsx index b381112a..9aaaf8e8 100644 --- a/src/components/Blocks/ControlTemplate.tsx +++ b/src/components/Blocks/ControlTemplate.tsx @@ -186,7 +186,9 @@ export const ControlTemplate: React.FC = ({ useLayoutEffect(() => { syncContentSize.current = () => { const element = document.getElementById(id); - if (!element) return; + // Not laid out: hidden while another page loads (React hides the + // canvas with display: none), so it measures 0 × 0. + if (!element || element.getClientRects().length === 0) return; const next = { w: element.offsetWidth, h: element.offsetHeight }; if (next.w === Number(size.w) && next.h === Number(size.h)) return; diff --git a/src/components/Blocks/HTMLBlock.tsx b/src/components/Blocks/HTMLBlock.tsx index 90784f8d..9d4c77b5 100644 --- a/src/components/Blocks/HTMLBlock.tsx +++ b/src/components/Blocks/HTMLBlock.tsx @@ -26,6 +26,7 @@ import { updateJSVariable, buildBlockStylesheet, createSafeDOM, + createScopedDocument, fileHandler, fileUtils, } from '../../lib/blocks-api'; @@ -104,42 +105,6 @@ export const HTMLBlock: React.FC = ({ id }) => { setDevLogs([]); }; - const createScopedDocument = ( - shadowRoot: ShadowRoot, - host: HTMLDivElement, - ): Document & ShadowRoot => { - const globalDocument = window.document; - - return new Proxy(globalDocument, { - get(target, prop) { - switch (prop) { - case 'querySelector': - return shadowRoot.querySelector.bind(shadowRoot); - case 'querySelectorAll': - return shadowRoot.querySelectorAll.bind(shadowRoot); - case 'getElementById': - return shadowRoot.getElementById?.bind(shadowRoot); - case 'body': - return shadowRoot; - case 'head': - return shadowRoot; - case 'documentElement': - return host; - case 'activeElement': - return shadowRoot.activeElement; - case 'addEventListener': - return shadowRoot.addEventListener.bind(shadowRoot); - case 'removeEventListener': - return shadowRoot.removeEventListener.bind(shadowRoot); - case 'dispatchEvent': - return shadowRoot.dispatchEvent.bind(shadowRoot); - default: - return Reflect.get(target, prop, target); - } - }, - }) as Document & ShadowRoot; - }; - // Update CSS content when variables change const handleUpdateCSSVariable = ( varName: string, diff --git a/src/components/Blocks/HTMLBlockBindingsPanel.tsx b/src/components/Blocks/HTMLBlockBindingsPanel.tsx index c1b1cac6..c91c3468 100644 --- a/src/components/Blocks/HTMLBlockBindingsPanel.tsx +++ b/src/components/Blocks/HTMLBlockBindingsPanel.tsx @@ -7,7 +7,6 @@ import { type JSVariable, } from '@/lib/blocks-api'; import { ColorPicker } from '../CustomControls/ColorPicker'; -import { Label } from '../ui/label'; import { Input } from '../ui/input'; import { Slider } from '../ui/slider'; import { Switch } from '../ui/switch'; @@ -15,6 +14,7 @@ import { ObjectEditor } from '../CustomControls/ObjectEditor'; import { ArrayEditor } from '../CustomControls/ArrayEditor'; import { ImageInput, FileInput } from '../CustomControls/FileInput'; import { ShadowEditor } from '../CustomControls/ShadowEditor'; +import { FieldInput, PropertyRow } from '../CustomControls/PropertyControls'; import { Button } from '../ui/button'; interface CSSControlsProps { @@ -36,112 +36,177 @@ interface ActionControlsProps { onExecuteAction: (action: CustomAction) => void; } +/** `--value-size` and `showValues` read as "Value size" and "Show values". */ +export const variableLabel = (name: string): string => { + const words = name + .replace(/^-+/, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[-_\s]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()); + const text = words.join(' '); + return text.charAt(0).toUpperCase() + text.slice(1) || name; +}; + +/** The label of a row, with the variable name on hover. */ +const RowLabel: React.FC<{ name: string }> = ({ name }) => ( + {variableLabel(name)} +); + +/** + * A slider with a field to type the exact value. Values past the declared + * range stay reachable: the slider grows to fit them. + */ +const NumberField: React.FC<{ + name: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + unit?: string; +}> = ({ name, value, onChange, min = 0, max, step = 1, unit }) => { + const number = Number(value) || 0; + const upper = Math.max(max ?? 100, number); + const lower = Math.min(min, number); + + return ( + }> + onChange(next[0])} + /> + { + const next = Number(text); + if (text.trim() !== '' && Number.isFinite(next)) onChange(next); + }} + className='w-16 flex-none' + /> + + ); +}; + +const TextField: React.FC<{ + name: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + mono?: boolean; +}> = ({ name, value, onChange, placeholder, mono }) => ( + }> + onChange(event.target.value)} + className={`h-7 text-xs md:text-xs ${mono ? 'font-mono' : ''}`} + /> + +); + +const BooleanField: React.FC<{ + name: string; + value: boolean; + onChange: (value: boolean) => void; +}> = ({ name, value, onChange }) => ( + }> + + +); + export const HTMLBlockCSSVariablesControls: React.FC = ({ variables, onUpdateVariable, }) => ( -
+
{variables.map((variable) => { + const update = (value: string | number | boolean) => + onUpdateVariable(variable.name, value); + switch (variable.type) { case 'color': return ( -
- - - onUpdateVariable(variable.name, color) - } - label='' - /> -
+ ); case 'number': return ( -
- -
- - onUpdateVariable(variable.name, value[0]) - } - value={[variable.value as number]} - min={variable.min || 0} - max={variable.max || 100} - step={variable.step || 1} - /> - - {variable.value} - -
-
+ ); case 'boolean': return ( -
- - - onUpdateVariable(variable.name, checked) - } - /> -
+ name={variable.name} + value={variable.value as boolean} + onChange={update} + /> ); case 'icon': return ( -
- -
- onUpdateVariable(variable.name, name)} - /> -
-
+ } + > + + ); case 'shadow': return ( -
+
onUpdateVariable(variable.name, value)} - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} />
); default: return ( -
- - - onUpdateVariable(variable.name, event.target.value) - } - className='h-7 font-mono text-xs' - /> -
+ ); } })} @@ -152,28 +217,25 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ variables, onUpdateVariable, }) => ( -
+
{variables.map((variable) => { + const update = (value: unknown) => onUpdateVariable(variable.name, value); + switch (variable.type) { case 'color': return ( -
- - - onUpdateVariable(variable.name, color) - } - label='' - /> -
+ ); case 'gradient': { - const gradientValue = variable.value as string; + const gradientValue = String(variable.value); let color1 = '#667eea'; let color2 = '#764ba2'; let angle = 45; @@ -188,136 +250,98 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ } return ( -
- - undefined} - onGradientChange={(newColor1, newColor2) => { - onUpdateVariable( - variable.name, - `linear-gradient(${angle}deg, ${newColor1}, ${newColor2})`, - ); - }} - onGradientDegChange={(newAngle) => { - onUpdateVariable( - variable.name, - `linear-gradient(${newAngle}deg, ${color1}, ${color2})`, - ); - }} - label='' - /> -
+ undefined} + onGradientChange={(newColor1, newColor2) => + update( + `linear-gradient(${angle}deg, ${newColor1}, ${newColor2})`, + ) + } + onGradientDegChange={(newAngle) => + update(`linear-gradient(${newAngle}deg, ${color1}, ${color2})`) + } + label={variableLabel(variable.name)} + /> ); } case 'number': return ( -
- -
- - onUpdateVariable(variable.name, value[0]) - } - value={[variable.value as number]} - min={variable.min || 0} - max={variable.max || 100} - step={variable.step || 1} - /> - - {variable.value as any} - -
-
+ ); case 'boolean': return ( -
- - - onUpdateVariable(variable.name, checked) - } - /> -
+ name={variable.name} + value={variable.value as boolean} + onChange={update} + /> ); case 'url': return ( -
- - - onUpdateVariable(variable.name, event.target.value) - } - placeholder='https://example.com' - className='h-7 font-mono text-xs' - /> -
+ ); case 'object': return ( -
+
) : {} } - onChange={(newValue) => - onUpdateVariable(variable.name, newValue) - } - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} />
); case 'array': return ( -
+
- onUpdateVariable(variable.name, newValue) - } - label={variable.name} - placeholder='Add items...' + onChange={update} + label={variableLabel(variable.name)} />
); case 'image': return ( -
+
- onUpdateVariable(variable.name, newValue) - } - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} multiple={variable.multiple} maxSize={variable.maxSize} maxFiles={variable.multiple ? 10 : 1} @@ -327,13 +351,11 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ case 'file': return ( -
+
- onUpdateVariable(variable.name, newValue) - } - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} accept={variable.accept} multiple={variable.multiple} maxSize={variable.maxSize} @@ -344,18 +366,12 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ default: return ( -
- - - onUpdateVariable(variable.name, event.target.value) - } - className='h-7 font-mono text-xs' - /> -
+ ); } })} diff --git a/src/components/Blocks/TextBlock.tsx b/src/components/Blocks/TextBlock.tsx index 0faf0904..b0b80994 100644 --- a/src/components/Blocks/TextBlock.tsx +++ b/src/components/Blocks/TextBlock.tsx @@ -34,6 +34,7 @@ import { SelectValue, } from '../ui/select'; import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores'; import { type TextAlign, type TextSizing, @@ -161,6 +162,10 @@ export const TextControl: React.FC = ({ id }) => { const weight = isBold ? 700 : fontWeight; + /* The hover and editing outlines are editor chrome: an export copies the + computed style of the paragraph, hover included, so drop them then. */ + const isExporting = useUIStore((state) => state.isExporting); + return ( <> = ({ id }) => { className={cn( // A block box, not a flex one, so `text-align` reaches the lines. // An outline, not a border: the hover must not resize the block. - 'my-auto block w-full flex-auto select-none overflow-hidden whitespace-pre-wrap hover:outline hover:outline-1 hover:outline-blue-500', + 'my-auto block w-full flex-auto select-none overflow-hidden whitespace-pre-wrap', + !isExporting && + 'hover:outline hover:outline-1 hover:outline-blue-500', sizing !== 'fixed' && 'break-words', // The app font only styles bold text that uses no font of its own. isBold && fontFamily === '' && 'poppins-font-family', isItalic && 'italic', isUnderline && 'underline', - editing && - 'cursor-text select-text outline outline-1 outline-blue-500', + editing && 'cursor-text select-text', + editing && !isExporting && 'outline outline-1 outline-blue-500', )} > {editing ? undefined : shownText} diff --git a/src/components/CustomControls/ArrayEditor.tsx b/src/components/CustomControls/ArrayEditor.tsx index 59e1fd13..3d1592e9 100644 --- a/src/components/CustomControls/ArrayEditor.tsx +++ b/src/components/CustomControls/ArrayEditor.tsx @@ -1,192 +1,146 @@ import React, { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { Plus, X, Edit2, Check, RotateCcw } from 'lucide-react'; +import { ChevronDown, ChevronUp, Plus, X } from 'lucide-react'; +import { cn } from '@/components/lib/utils'; +import { DraftInput } from './DraftInput'; interface Props { - value: string[]; + /** Items that are not strings (numbers written by hand) are edited as text. */ + value: unknown[]; onChange: (value: string[]) => void; placeholder?: string; label?: string; } +const IconButton: React.FC<{ + label: string; + onClick: () => void; + disabled?: boolean; + className?: string; + children: React.ReactNode; +}> = ({ label, onClick, disabled, className, children }) => ( + +); + +/** + * A list of text items, one field per item: edit in place (Enter or leaving + * the field applies it), reorder, remove, and add at the end. + */ export const ArrayEditor: React.FC = ({ value = [], onChange, - placeholder = 'Add items...', + placeholder = 'New item', label, }) => { - const [newItem, setNewItem] = useState(''); - const [editingIndex, setEditingIndex] = useState(null); - const [editingValue, setEditingValue] = useState(''); - - const addItem = () => { - if (newItem.trim()) { - const newValue = [...value, newItem.trim()]; - onChange(newValue); - setNewItem(''); - } - }; - - const removeItem = (index: number) => { - const newValue = value.filter((_, i) => i !== index); - onChange(newValue); - }; - - const startEditing = (index: number) => { - setEditingIndex(index); - setEditingValue(value[index]); - }; - - const saveEdit = () => { - if (editingIndex !== null && editingValue.trim()) { - const newValue = [...value]; - newValue[editingIndex] = editingValue.trim(); - onChange(newValue); - setEditingIndex(null); - setEditingValue(''); - } - }; - - const cancelEdit = () => { - setEditingIndex(null); - setEditingValue(''); - }; + const items = value.map((item) => + typeof item === 'string' ? item : JSON.stringify(item), + ); + const [adding, setAdding] = useState(false); + // Remounts the new item's field after each add, so it starts empty. + const [addedCount, setAddedCount] = useState(0); - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - addItem(); - } - }; + const replace = (index: number, text: string) => + onChange(items.map((item, current) => (current === index ? text : item))); - const handleEditKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - saveEdit(); - } else if (e.key === 'Escape') { - cancelEdit(); - } - }; + const remove = (index: number) => + onChange(items.filter((_, current) => current !== index)); - const clearAll = () => { - onChange([]); + const move = (index: number, offset: -1 | 1) => { + const next = [...items]; + const [item] = next.splice(index, 1); + next.splice(index + offset, 0, item); + onChange(next); }; return ( -
- {label && ( - - )} - - {/* Add new item */} -
- setNewItem(e.target.value)} - onKeyPress={handleKeyPress} - placeholder={placeholder} - className='text-sm' - /> - +
+
+ {label && ( + + {label} + + )} + + {items.length} item{items.length === 1 ? '' : 's'} +
- {/* Items list */} - {value.length > 0 && ( -
-
- - {value.length} item{value.length !== 1 ? 's' : ''} - - -
- -
- {value.map((item, index) => ( - - {editingIndex === index ? ( - setEditingValue(e.target.value)} - onKeyPress={handleEditKeyPress} - onBlur={saveEdit} - className='h-5 w-20 text-xs' - autoFocus - /> - ) : ( - {item} - )} - -
- {editingIndex === index ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- ))} -
-
+ {items.length > 0 && ( +
    + {items.map((item, index) => ( +
  1. + + {index + 1} + + replace(index, text)} + /> +
    + move(index, -1)} + > + + + move(index, 1)} + > + + + remove(index)} + className='hover:bg-destructive/15 hover:text-destructive' + > + + +
    +
  2. + ))} +
)} - {value.length === 0 && ( -
- No items yet. Add your first item above. + {adding ? ( +
+ { + if (text.trim() === '') return; + onChange([...items, text]); + setAddedCount((count) => count + 1); + }} + onBlur={() => setAdding(false)} + />
+ ) : ( + )}
); diff --git a/src/components/CustomControls/DraftInput.tsx b/src/components/CustomControls/DraftInput.tsx new file mode 100644 index 00000000..153ff96e --- /dev/null +++ b/src/components/CustomControls/DraftInput.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { cn } from '@/components/lib/utils'; + +/** + * A text field that commits on Enter or when it loses focus, not on every + * key: each commit is one undo step and one re-render of the block. Escape + * brings the value back. It follows the value when it changes elsewhere + * (undo, the code editor, Agent). + */ +export const DraftInput: React.FC< + Omit, 'value' | 'onChange'> & { + value: string; + onCommit: (value: string) => void; + /** Called after Enter commits, e.g. to move on to the next field. */ + onEnter?: () => void; + } +> = ({ value, onCommit, onEnter, className, onKeyDown, onBlur, ...props }) => { + const [draft, setDraft] = useState(value); + const [synced, setSynced] = useState(value); + if (value !== synced) { + setSynced(value); + setDraft(value); + } + + const commit = () => { + if (draft !== value) onCommit(draft); + }; + + return ( + setDraft(event.currentTarget.value)} + onBlur={(event) => { + commit(); + onBlur?.(event); + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (event.key === 'Enter') { + event.preventDefault(); + commit(); + onEnter?.(); + } else if (event.key === 'Escape') { + setDraft(value); + event.currentTarget.blur(); + } + }} + className={cn( + 'h-7 w-full min-w-0 rounded-control border border-input bg-transparent px-2 text-xs text-foreground outline-none transition-[color,box-shadow] placeholder:text-muted-foreground hover:border-ring/60 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20', + className, + )} + /> + ); +}; diff --git a/src/components/CustomControls/ObjectEditor.tsx b/src/components/CustomControls/ObjectEditor.tsx index 5c7ff883..1807b6cc 100644 --- a/src/components/CustomControls/ObjectEditor.tsx +++ b/src/components/CustomControls/ObjectEditor.tsx @@ -1,305 +1,192 @@ import React, { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { Plus, X, Edit2, Check, RotateCcw, FileJson, Eye } from 'lucide-react'; +import { Braces, List, Plus, X } from 'lucide-react'; +import { cn } from '@/components/lib/utils'; import { Textarea } from '@/components/ui/textarea'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { DraftInput } from './DraftInput'; interface Props { - value: Record; - onChange: (value: Record) => void; - placeholder?: string; + value: Record; + onChange: (value: Record) => void; label?: string; } +/** How a value shows in its field: text as is, anything else as JSON. */ +const toText = (value: unknown): string => + typeof value === 'string' ? value : JSON.stringify(value); + +/** + * Read a typed field back with the type the value had: numbers stay + * numbers, booleans booleans and nested values JSON when the text allows. + */ +export const fromText = (text: string, previous: unknown): unknown => { + if (typeof previous === 'string') return text; + if (typeof previous === 'number') { + const number = Number(text); + return text.trim() !== '' && Number.isFinite(number) ? number : text; + } + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +/** The whole object as JSON, applied when it is valid and the field is left. */ +const JsonField: React.FC<{ + value: Record; + onChange: (value: Record) => void; +}> = ({ value, onChange }) => { + const formatted = JSON.stringify(value, null, 2); + const [draft, setDraft] = useState(formatted); + const [synced, setSynced] = useState(formatted); + if (formatted !== synced) { + setSynced(formatted); + setDraft(formatted); + } + + let parsed: unknown; + let valid = true; + try { + parsed = JSON.parse(draft); + valid = + typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed); + } catch { + valid = false; + } + + return ( +
+