@agent-native/core 0.85.6 → 0.85.7
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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/AGENTS.md +33 -1
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +2 -2
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/write-local-file.ts +47 -22
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +18 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +200 -23
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -8,24 +8,35 @@
|
|
|
8
8
|
* jump and the B6 ellipse border-radius jump.
|
|
9
9
|
*
|
|
10
10
|
* Design decisions:
|
|
11
|
-
* - Fill: `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* - Fill (rect/ellipse): `rgb(218 218 218)` — a plain, theme-independent
|
|
12
|
+
* neutral gray, not a CSS custom property. This is intentional: a shape
|
|
13
|
+
* drawn with no explicit color should look the same (a soft Figma-like
|
|
14
|
+
* gray) regardless of which document theme it lands in, rather than
|
|
15
|
+
* silently tinting to whatever `--primary` resolves to there.
|
|
16
|
+
* - Stroke (rect/ellipse): `rgb(168 168 168)` — a slightly darker plain gray,
|
|
17
|
+
* same rationale as fill above.
|
|
18
|
+
* - Frame fill: the one default that *is* theme-adaptive —
|
|
19
|
+
* `hsl(var(--primary) / 0.05)`, a very faint tint of the editor's accent
|
|
20
|
+
* color so a layout frame's interior reads as "structural chrome" rather
|
|
21
|
+
* than a fixed gray. Its border stays a plain dashed gray, same as
|
|
22
|
+
* rect/ellipse's border.
|
|
16
23
|
* - Stroke width: 1px for div-based shapes.
|
|
17
24
|
* - Ellipse: borderRadius "50%" in both paths — no more "oval on commit" jump.
|
|
18
25
|
* - Rect: borderRadius "2px" (small, matches the previous committed value; the
|
|
19
26
|
* preview used Tailwind `rounded-sm` which resolves to 2px).
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
27
|
+
* - Text: inherits current color by default. Selection/edit chrome owns
|
|
28
|
+
* outlines so text does not carry a persistent border that double-stacks
|
|
29
|
+
* with focused states.
|
|
23
30
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
31
|
+
* Overrides: when a caller passes a `fill` override, it becomes the
|
|
32
|
+
* `background` for every kind EXCEPT text — a text primitive's `fill` maps to
|
|
33
|
+
* `color` instead (text has no fillable background box; DesignEditor's
|
|
34
|
+
* `appendCanvasPrimitiveToHtml` does the same `primitive.fill ?? "currentColor"`
|
|
35
|
+
* mapping for the committed element). Getting this wrong here previously
|
|
36
|
+
* meant a text primitive's chosen color rendered in the preview but was
|
|
37
|
+
* silently dropped, then jumped to the correct color only once committed.
|
|
38
|
+
* `stroke` / `strokeWidth` overrides are passed through unchanged for every
|
|
39
|
+
* kind.
|
|
29
40
|
*/
|
|
30
41
|
|
|
31
42
|
import type * as React from "react";
|
|
@@ -152,18 +163,23 @@ export function canvasPrimitiveStyleString(
|
|
|
152
163
|
overrides?: { fill?: string; stroke?: string; strokeWidth?: number },
|
|
153
164
|
): string {
|
|
154
165
|
const v = canvasPrimitiveVisual(kind);
|
|
166
|
+
const isText = kind === "text";
|
|
155
167
|
|
|
156
168
|
// Apply caller overrides so user-chosen colours are preserved.
|
|
157
169
|
let background = v.background;
|
|
158
170
|
let border = v.border;
|
|
159
171
|
|
|
160
|
-
|
|
172
|
+
// Text primitives render their fill as `color`, not `background` (see the
|
|
173
|
+
// matching note in canvasPrimitiveReactStyle) — otherwise a caller-chosen
|
|
174
|
+
// text color would paint a filled background box instead of tinting the
|
|
175
|
+
// glyphs, and would disagree with the committed HTML output.
|
|
176
|
+
if (overrides?.fill && !isText) {
|
|
161
177
|
background = overrides.fill;
|
|
162
178
|
}
|
|
163
179
|
if (overrides?.stroke || overrides?.strokeWidth !== undefined) {
|
|
164
180
|
const stroke = overrides.stroke ?? DEFAULT_STROKE;
|
|
165
181
|
const width = overrides.strokeWidth ?? DEFAULT_STROKE_WIDTH_PX;
|
|
166
|
-
const style = kind === "frame" ||
|
|
182
|
+
const style = kind === "frame" || isText ? "dashed" : "solid";
|
|
167
183
|
border = `${width}px ${style} ${stroke}`;
|
|
168
184
|
}
|
|
169
185
|
|
|
@@ -173,8 +189,9 @@ export function canvasPrimitiveStyleString(
|
|
|
173
189
|
`border-radius:${v.borderRadius}`,
|
|
174
190
|
];
|
|
175
191
|
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
const color = isText ? overrides?.fill || v.color || "currentColor" : v.color;
|
|
193
|
+
if (color) {
|
|
194
|
+
parts.push(`color:${color}`);
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
return parts.join(";");
|
|
@@ -198,26 +215,30 @@ export function canvasPrimitiveReactStyle(
|
|
|
198
215
|
overrides?: { fill?: string; stroke?: string; strokeWidth?: number },
|
|
199
216
|
): React.CSSProperties {
|
|
200
217
|
const v = canvasPrimitiveVisual(kind);
|
|
218
|
+
const isText = kind === "text";
|
|
201
219
|
|
|
202
220
|
let background = v.background as string | undefined;
|
|
203
221
|
let borderColor: string | undefined;
|
|
204
222
|
let borderWidth: number | string | undefined = DEFAULT_STROKE_WIDTH_PX;
|
|
205
223
|
let borderStyle: string | undefined = "solid";
|
|
206
224
|
|
|
207
|
-
if (kind === "frame" ||
|
|
225
|
+
if (kind === "frame" || isText) {
|
|
208
226
|
borderStyle = "dashed";
|
|
209
227
|
}
|
|
210
228
|
|
|
211
|
-
if (
|
|
212
|
-
kind === "text" &&
|
|
213
|
-
!overrides?.stroke &&
|
|
214
|
-
overrides?.strokeWidth === undefined
|
|
215
|
-
) {
|
|
229
|
+
if (isText && !overrides?.stroke && overrides?.strokeWidth === undefined) {
|
|
216
230
|
borderWidth = 0;
|
|
217
231
|
borderStyle = "solid";
|
|
218
232
|
}
|
|
219
233
|
|
|
220
|
-
|
|
234
|
+
// Text primitives render their fill as `color`, not `background` — the
|
|
235
|
+
// element itself stays transparent so it doesn't paint a filled box behind
|
|
236
|
+
// the glyphs. Committed HTML output (DesignEditor's
|
|
237
|
+
// appendCanvasPrimitiveToHtml) already maps `primitive.fill` to
|
|
238
|
+
// `element.style.color`; this must match exactly or a text primitive with
|
|
239
|
+
// a custom fill shows the wrong color in the draft preview and visibly
|
|
240
|
+
// jumps to the correct color the moment it commits.
|
|
241
|
+
if (overrides?.fill && !isText) {
|
|
221
242
|
background = overrides.fill;
|
|
222
243
|
}
|
|
223
244
|
if (overrides?.stroke) {
|
|
@@ -245,11 +266,14 @@ export function canvasPrimitiveReactStyle(
|
|
|
245
266
|
style.color = v.color;
|
|
246
267
|
}
|
|
247
268
|
|
|
248
|
-
|
|
249
|
-
if (kind === "text") {
|
|
269
|
+
if (isText) {
|
|
250
270
|
style.background = "transparent";
|
|
251
271
|
style.outline = "none";
|
|
252
272
|
style.outlineOffset = 0;
|
|
273
|
+
// Matches DesignEditor's `primitive.fill ?? "currentColor"` convention
|
|
274
|
+
// for the committed element, so the preview and the commit never
|
|
275
|
+
// disagree on text color.
|
|
276
|
+
style.color = overrides?.fill || "currentColor";
|
|
253
277
|
}
|
|
254
278
|
|
|
255
279
|
return style;
|
|
@@ -344,7 +344,15 @@ export function AutoLayoutMatrix({
|
|
|
344
344
|
<IconFlowGrid className="size-3.5" />
|
|
345
345
|
</FlowButton>
|
|
346
346
|
</div>
|
|
347
|
-
{/*
|
|
347
|
+
{/* Swap axis: flips between horizontal/vertical flex flow
|
|
348
|
+
without forcing a specific direction. Previously this
|
|
349
|
+
unconditionally called selectFlow("horizontal"), silently
|
|
350
|
+
destroying an authored vertical/grid/normal-flow layout
|
|
351
|
+
every time it was clicked regardless of the current state
|
|
352
|
+
(there's no stashed "authored" value to restore to, so a
|
|
353
|
+
true reset isn't possible) — swapping is the one action
|
|
354
|
+
here that's both non-destructive and matches what an
|
|
355
|
+
undo/reverse-style icon implies. */}
|
|
348
356
|
<Tooltip>
|
|
349
357
|
<TooltipTrigger asChild>
|
|
350
358
|
<Button
|
|
@@ -353,16 +361,20 @@ export function AutoLayoutMatrix({
|
|
|
353
361
|
size="icon"
|
|
354
362
|
disabled={disabled}
|
|
355
363
|
aria-label={
|
|
356
|
-
"
|
|
364
|
+
"Swap flow direction" /* i18n-ignore inspector tooltip */
|
|
365
|
+
}
|
|
366
|
+
onClick={() =>
|
|
367
|
+
selectFlow(
|
|
368
|
+
activeFlow === "vertical" ? "horizontal" : "vertical",
|
|
369
|
+
)
|
|
357
370
|
}
|
|
358
|
-
onClick={() => selectFlow("horizontal")}
|
|
359
371
|
className="size-7 shrink-0 rounded-md text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground"
|
|
360
372
|
>
|
|
361
373
|
<IconArrowBackUp className="size-4" />
|
|
362
374
|
</Button>
|
|
363
375
|
</TooltipTrigger>
|
|
364
376
|
<TooltipContent>
|
|
365
|
-
{"
|
|
377
|
+
{"Swap flow direction" /* i18n-ignore inspector tooltip */}
|
|
366
378
|
</TooltipContent>
|
|
367
379
|
</Tooltip>
|
|
368
380
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
alphaToOpacity,
|
|
3
3
|
parseCssColor,
|
|
4
|
+
parseCssColorExtended,
|
|
4
5
|
rgbaToCss,
|
|
5
6
|
rgbaToHex,
|
|
6
7
|
rgbaToHsl,
|
|
@@ -204,86 +205,10 @@ interface HsvaColor {
|
|
|
204
205
|
|
|
205
206
|
const FALLBACK_COLOR: RgbaColor = { r: 0, g: 0, b: 0, a: 1 };
|
|
206
207
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// `
|
|
210
|
-
//
|
|
211
|
-
// getComputedStyle: space-separated `rgb(R G B)`, `rgb(R G B / A)`, and
|
|
212
|
-
// opaque formats like `oklch(...)` or `color(display-p3 ...)`.
|
|
213
|
-
//
|
|
214
|
-
// This local wrapper extends the parser to cover those cases so that colors
|
|
215
|
-
// arriving from the canvas's computed-style bridge are always usable.
|
|
216
|
-
|
|
217
|
-
const MODERN_RGB_PATTERN =
|
|
218
|
-
/^rgba?\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*\/\s*([0-9.]+%?))?\s*\)$/i;
|
|
219
|
-
|
|
220
|
-
/** Canvas element reused across calls for DOM-based color resolution. */
|
|
221
|
-
let _resolverCanvas: HTMLCanvasElement | null = null;
|
|
222
|
-
let _resolverCtx: CanvasRenderingContext2D | null = null;
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Parses a CSS color string into RgbaColor, extending the base parser with:
|
|
226
|
-
* - Modern space-separated `rgb(R G B)` / `rgb(R G B / A)` syntax
|
|
227
|
-
* - Opaque formats (oklch, color, etc.) resolved via a hidden canvas
|
|
228
|
-
*
|
|
229
|
-
* Falls back to null if the value is unparseable and the DOM is unavailable.
|
|
230
|
-
*/
|
|
231
|
-
function parseCssColorExtended(value: string): RgbaColor | null {
|
|
232
|
-
// 1. Try the standard parser first (handles hex, comma rgb/rgba, hsl/hsla).
|
|
233
|
-
const standard = parseCssColor(value);
|
|
234
|
-
if (standard) return standard;
|
|
235
|
-
|
|
236
|
-
const trimmed = value.trim();
|
|
237
|
-
if (!trimmed || trimmed === "transparent" || trimmed === "none") return null;
|
|
238
|
-
|
|
239
|
-
// 2. Modern space-separated rgb/rgba — CSS Level 4.
|
|
240
|
-
const modernRgb = trimmed.match(MODERN_RGB_PATTERN);
|
|
241
|
-
if (modernRgb) {
|
|
242
|
-
const parseAlphaLocal = (v: string | undefined): number => {
|
|
243
|
-
if (!v) return 1;
|
|
244
|
-
if (v.endsWith("%"))
|
|
245
|
-
return Math.max(0, Math.min(1, Number(v.slice(0, -1)) / 100));
|
|
246
|
-
return Math.max(0, Math.min(1, Number(v)));
|
|
247
|
-
};
|
|
248
|
-
return {
|
|
249
|
-
r: Math.round(Math.max(0, Math.min(255, Number(modernRgb[1])))),
|
|
250
|
-
g: Math.round(Math.max(0, Math.min(255, Number(modernRgb[2])))),
|
|
251
|
-
b: Math.round(Math.max(0, Math.min(255, Number(modernRgb[3])))),
|
|
252
|
-
a: parseAlphaLocal(modernRgb[4]),
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 3. DOM-based resolver for oklch, color(display-p3 ...), hsl (modern), etc.
|
|
257
|
-
// Uses a hidden 1×1 canvas to resolve any valid CSS color to rgb().
|
|
258
|
-
if (typeof document === "undefined") return null;
|
|
259
|
-
try {
|
|
260
|
-
if (!_resolverCanvas) {
|
|
261
|
-
_resolverCanvas = document.createElement("canvas");
|
|
262
|
-
_resolverCanvas.width = 1;
|
|
263
|
-
_resolverCanvas.height = 1;
|
|
264
|
-
}
|
|
265
|
-
if (!_resolverCtx) {
|
|
266
|
-
_resolverCtx = _resolverCanvas.getContext("2d", {
|
|
267
|
-
willReadFrequently: true,
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
const ctx = _resolverCtx;
|
|
271
|
-
if (!ctx) return null;
|
|
272
|
-
// Detect invalid color values: save fillStyle before and after assignment.
|
|
273
|
-
// If the browser rejects the value, fillStyle won't change.
|
|
274
|
-
const prev = ctx.fillStyle;
|
|
275
|
-
ctx.fillStyle = trimmed;
|
|
276
|
-
const next = ctx.fillStyle; // browser normalises to rgb/hex on accept
|
|
277
|
-
// If the value was rejected, fillStyle stays at the previous value.
|
|
278
|
-
if (next === prev) return null;
|
|
279
|
-
ctx.clearRect(0, 0, 1, 1);
|
|
280
|
-
ctx.fillRect(0, 0, 1, 1);
|
|
281
|
-
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
282
|
-
return { r, g, b, a: a / 255 };
|
|
283
|
-
} catch {
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
208
|
+
// `parseCssColorExtended` (from color-utils) extends `parseCssColor` with
|
|
209
|
+
// modern CSS Level 4 syntax (space-separated rgb, `oklch(...)`,
|
|
210
|
+
// `color(display-p3 ...)`) so that colors arriving from the canvas's
|
|
211
|
+
// computed-style bridge are always usable.
|
|
287
212
|
|
|
288
213
|
const DEFAULT_LABELS: DesignColorPickerLabels = {
|
|
289
214
|
trigger: "Open color picker", // i18n-ignore fallback component label
|
|
@@ -865,13 +790,20 @@ export function DesignColorPicker({
|
|
|
865
790
|
emitColor(hslToRgba({ ...nextHsl, a: opacityToAlpha(effectiveOpacity) }));
|
|
866
791
|
};
|
|
867
792
|
|
|
793
|
+
// Shared by the invalid-commit path below and the Escape handler in the hex
|
|
794
|
+
// input: reverts the draft to the currently active color (the selected
|
|
795
|
+
// gradient stop while editing a gradient, otherwise the solid color).
|
|
796
|
+
const revertHexDraft = () => {
|
|
797
|
+
const reverted = toDisplayHex(activeGradient ? fieldColor : color);
|
|
798
|
+
hexDraftRef.current = reverted;
|
|
799
|
+
setHexDraft(reverted);
|
|
800
|
+
};
|
|
801
|
+
|
|
868
802
|
const commitHex = () => {
|
|
869
|
-
const currentDraft = hexDraftRef.current;
|
|
803
|
+
const currentDraft = expandHexShorthand(hexDraftRef.current);
|
|
870
804
|
const parsed = parseCssColor(`#${currentDraft.replace(/^#/, "")}`);
|
|
871
805
|
if (!parsed) {
|
|
872
|
-
|
|
873
|
-
hexDraftRef.current = reverted;
|
|
874
|
-
setHexDraft(reverted);
|
|
806
|
+
revertHexDraft();
|
|
875
807
|
return;
|
|
876
808
|
}
|
|
877
809
|
if (activeGradient) {
|
|
@@ -913,9 +845,14 @@ export function DesignColorPicker({
|
|
|
913
845
|
: color;
|
|
914
846
|
const rawFieldHsv = rgbaToHsv(fieldColor);
|
|
915
847
|
// Preserve the last non-zero hue so dragging through gray doesn't lose it.
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
848
|
+
// Recorded as an effect (not mutated during render) so the ref update is a
|
|
849
|
+
// render side-effect, not a render-body mutation.
|
|
850
|
+
useEffect(() => {
|
|
851
|
+
if (rawFieldHsv.s > 0 && rawFieldHsv.v > 0) {
|
|
852
|
+
lastHueRef.current = rawFieldHsv.h;
|
|
853
|
+
}
|
|
854
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
855
|
+
}, [rawFieldHsv.h, rawFieldHsv.s, rawFieldHsv.v]);
|
|
919
856
|
const fieldHsv: HsvaColor =
|
|
920
857
|
rawFieldHsv.s === 0
|
|
921
858
|
? { ...rawFieldHsv, h: lastHueRef.current }
|
|
@@ -1099,9 +1036,7 @@ export function DesignColorPicker({
|
|
|
1099
1036
|
e.currentTarget.blur();
|
|
1100
1037
|
}
|
|
1101
1038
|
if (e.key === "Escape") {
|
|
1102
|
-
|
|
1103
|
-
hexDraftRef.current = reverted;
|
|
1104
|
-
setHexDraft(reverted);
|
|
1039
|
+
revertHexDraft();
|
|
1105
1040
|
skipNextHexBlurCommitRef.current = true;
|
|
1106
1041
|
e.currentTarget.blur();
|
|
1107
1042
|
}
|
|
@@ -1235,12 +1170,12 @@ export function DesignColorPicker({
|
|
|
1235
1170
|
// switch causes the canvas to re-project the element. Without this,
|
|
1236
1171
|
// Radix treats the resulting focus shift as an "interact outside" event
|
|
1237
1172
|
// and closes the popover before the type switch is visible.
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
}
|
|
1173
|
+
// Radix fires a dedicated `onFocusOutside` (not `onInteractOutside`
|
|
1174
|
+
// with e.type === "focusoutside" — that never matches) whenever focus
|
|
1175
|
+
// moves outside the content; suppress it so canvas-driven focus
|
|
1176
|
+
// re-projection can't close the popover. Genuine pointer clicks
|
|
1177
|
+
// outside still close it via the default onInteractOutside behavior.
|
|
1178
|
+
onFocusOutside={(e) => e.preventDefault()}
|
|
1244
1179
|
>
|
|
1245
1180
|
<div className="rounded-md bg-popover text-popover-foreground">
|
|
1246
1181
|
{view === "shader" ? (
|
|
@@ -1475,6 +1410,13 @@ export function DesignColorPicker({
|
|
|
1475
1410
|
backgroundImage="linear-gradient(90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)"
|
|
1476
1411
|
onChange={(next) => {
|
|
1477
1412
|
const h = next === 360 ? 0 : next;
|
|
1413
|
+
// Record the dragged hue immediately so the slider
|
|
1414
|
+
// doesn't snap back to a stale hue on the next
|
|
1415
|
+
// render when the color is (or becomes) achromatic
|
|
1416
|
+
// — the round-tripped color's s/v may still be 0,
|
|
1417
|
+
// so the lastHueRef-sync effect's s>0&&v>0 guard
|
|
1418
|
+
// won't fire on its own.
|
|
1419
|
+
lastHueRef.current = h;
|
|
1478
1420
|
if (activeGradient) {
|
|
1479
1421
|
emitStopColor(
|
|
1480
1422
|
hsvToRgba({
|
|
@@ -1627,7 +1569,7 @@ export function DesignColorPicker({
|
|
|
1627
1569
|
: [rgbaToCss(color)]
|
|
1628
1570
|
).map((docColor) => {
|
|
1629
1571
|
const currentHex = rgbaToHex(
|
|
1630
|
-
|
|
1572
|
+
parseCssColorExtended(docColor) ?? color,
|
|
1631
1573
|
);
|
|
1632
1574
|
const isActive =
|
|
1633
1575
|
rgbaToHex(color) === currentHex && !activeGradient;
|
|
@@ -1647,7 +1589,8 @@ export function DesignColorPicker({
|
|
|
1647
1589
|
)}
|
|
1648
1590
|
style={swatchStyle(docColor)}
|
|
1649
1591
|
onClick={() => {
|
|
1650
|
-
const parsed =
|
|
1592
|
+
const parsed =
|
|
1593
|
+
parseCssColorExtended(docColor) ?? color;
|
|
1651
1594
|
if (activeGradient) emitStopColor(parsed);
|
|
1652
1595
|
else emitColor(parsed);
|
|
1653
1596
|
}}
|
|
@@ -1999,8 +1942,8 @@ function ScrubbyNumberInput({
|
|
|
1999
1942
|
}, [value]);
|
|
2000
1943
|
|
|
2001
1944
|
const commit = () => {
|
|
2002
|
-
const parsed =
|
|
2003
|
-
if (
|
|
1945
|
+
const parsed = parseNumericDraft(draftRef.current);
|
|
1946
|
+
if (parsed === null) {
|
|
2004
1947
|
const reverted = String(value);
|
|
2005
1948
|
draftRef.current = reverted;
|
|
2006
1949
|
setDraft(reverted);
|
|
@@ -2095,7 +2038,7 @@ export function inferPaintType(
|
|
|
2095
2038
|
return "linear";
|
|
2096
2039
|
}
|
|
2097
2040
|
if (lower.startsWith("url(")) return "image";
|
|
2098
|
-
const parsed =
|
|
2041
|
+
const parsed = parseCssColorExtended(value);
|
|
2099
2042
|
if (opacity <= 0 || parsed?.a === 0 || value.trim() === "transparent") {
|
|
2100
2043
|
return "none";
|
|
2101
2044
|
}
|
|
@@ -2189,13 +2132,19 @@ function triggerSwatchStyle(
|
|
|
2189
2132
|
return swatchStyle(rgbaToCss(color));
|
|
2190
2133
|
}
|
|
2191
2134
|
|
|
2135
|
+
/** Values that render their own background without needing color parsing. */
|
|
2136
|
+
function looksLikeImageOrGradient(value: string): boolean {
|
|
2137
|
+
const lower = value.trim().toLowerCase();
|
|
2138
|
+
return lower.includes("gradient(") || lower.startsWith("url(");
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2192
2141
|
function swatchStyle(value: string): {
|
|
2193
2142
|
backgroundColor?: string;
|
|
2194
2143
|
backgroundImage?: string;
|
|
2195
2144
|
backgroundSize?: string;
|
|
2196
2145
|
backgroundPosition?: string;
|
|
2197
2146
|
} {
|
|
2198
|
-
const parsed =
|
|
2147
|
+
const parsed = parseCssColorExtended(value);
|
|
2199
2148
|
if (parsed && parsed.a < 1) {
|
|
2200
2149
|
return {
|
|
2201
2150
|
backgroundImage: `${CHECKERBOARD_IMAGE}, linear-gradient(${rgbaToCss(parsed)}, ${rgbaToCss(parsed)})`,
|
|
@@ -2204,7 +2153,16 @@ function swatchStyle(value: string): {
|
|
|
2204
2153
|
};
|
|
2205
2154
|
}
|
|
2206
2155
|
if (parsed) return { backgroundColor: rgbaToCss(parsed) };
|
|
2207
|
-
|
|
2156
|
+
if (value && looksLikeImageOrGradient(value)) {
|
|
2157
|
+
return { backgroundImage: value };
|
|
2158
|
+
}
|
|
2159
|
+
// Unparseable and not a gradient/image value (e.g. a stale/invalid document
|
|
2160
|
+
// color) — show a neutral checkerboard instead of an invalid `background-image`
|
|
2161
|
+
// that would otherwise render as a blank swatch.
|
|
2162
|
+
return {
|
|
2163
|
+
backgroundImage: CHECKERBOARD_IMAGE,
|
|
2164
|
+
backgroundSize: "8px 8px",
|
|
2165
|
+
};
|
|
2208
2166
|
}
|
|
2209
2167
|
|
|
2210
2168
|
function alphaTrackBackground(color: RgbaColor): string {
|
|
@@ -2275,3 +2233,32 @@ function clampFloat(value: number, min: number, max: number): number {
|
|
|
2275
2233
|
function hasHexAlpha(value: string): boolean {
|
|
2276
2234
|
return /^#?(?:[0-9a-f]{4}|[0-9a-f]{8})$/i.test(value.trim());
|
|
2277
2235
|
}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* Parses a `ScrubbyNumberInput` draft string into a finite number, or `null`
|
|
2239
|
+
* when the draft should be treated as invalid (and thus reverted rather than
|
|
2240
|
+
* committed). `Number("")` is `0`, not `NaN`, so an emptied field must be
|
|
2241
|
+
* special-cased — otherwise clearing the input and blurring/pressing Enter
|
|
2242
|
+
* would silently commit `0` instead of reverting to the last real value.
|
|
2243
|
+
*/
|
|
2244
|
+
export function parseNumericDraft(draft: string): number | null {
|
|
2245
|
+
const trimmed = draft.trim();
|
|
2246
|
+
if (trimmed === "") return null;
|
|
2247
|
+
const parsed = Number(trimmed);
|
|
2248
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* Expands a bare 1-digit hex fragment (e.g. "F") into the standard 3-digit
|
|
2253
|
+
* shorthand ("FFF", which `parseCssColor` then doubles up to "FFFFFF") so a
|
|
2254
|
+
* single typed hex digit still commits instead of being rejected as
|
|
2255
|
+
* unparseable. Standard 3/4/6/8-digit hex (already handled by
|
|
2256
|
+
* `parseCssColor`) passes through unchanged.
|
|
2257
|
+
*/
|
|
2258
|
+
export function expandHexShorthand(value: string): string {
|
|
2259
|
+
const trimmed = value.trim().replace(/^#/, "");
|
|
2260
|
+
if (/^[0-9a-f]$/i.test(trimmed)) {
|
|
2261
|
+
return trimmed.repeat(3);
|
|
2262
|
+
}
|
|
2263
|
+
return trimmed;
|
|
2264
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { parseCssColor, rgbaToCss } from "@shared/color-utils";
|
|
2
2
|
import { IconTrash } from "@tabler/icons-react";
|
|
3
3
|
import {
|
|
4
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
4
5
|
type PointerEvent as ReactPointerEvent,
|
|
6
|
+
useEffect,
|
|
5
7
|
useRef,
|
|
6
8
|
useState,
|
|
7
9
|
} from "react";
|
|
@@ -83,6 +85,44 @@ function clamp(n: number, min: number, max: number): number {
|
|
|
83
85
|
return Math.max(min, Math.min(max, n));
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Parses a stop-position draft string into a finite 0–100 number, or `null`
|
|
90
|
+
* when the draft is invalid/empty and the field should revert instead of
|
|
91
|
+
* committing (mirrors `parseNumericDraft` in DesignColorPicker.tsx).
|
|
92
|
+
*/
|
|
93
|
+
export function parseStopPositionDraft(draft: string): number | null {
|
|
94
|
+
const trimmed = draft.trim();
|
|
95
|
+
if (trimmed === "") return null;
|
|
96
|
+
const parsed = Number(trimmed);
|
|
97
|
+
return Number.isFinite(parsed) ? clamp(parsed, 0, 100) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Picks which remaining stop should become selected after deleting one.
|
|
102
|
+
* Returns the id of the stop whose position is closest to the removed
|
|
103
|
+
* stop's position (ties broken by whichever appears first in `stops`),
|
|
104
|
+
* rather than always jumping to the leftmost stop — deleting a stop near
|
|
105
|
+
* the right edge of the ramp should keep selection nearby, not teleport
|
|
106
|
+
* the user's focus across the gradient.
|
|
107
|
+
*/
|
|
108
|
+
export function nearestStopId(
|
|
109
|
+
stops: GradientStopValue[],
|
|
110
|
+
removedPosition: number | undefined,
|
|
111
|
+
): string | null {
|
|
112
|
+
if (stops.length === 0) return null;
|
|
113
|
+
if (removedPosition === undefined) return sortedStops(stops)[0]?.id ?? null;
|
|
114
|
+
let best: GradientStopValue | null = null;
|
|
115
|
+
let bestDistance = Infinity;
|
|
116
|
+
for (const stop of stops) {
|
|
117
|
+
const distance = Math.abs(stop.position - removedPosition);
|
|
118
|
+
if (distance < bestDistance) {
|
|
119
|
+
best = stop;
|
|
120
|
+
bestDistance = distance;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return best?.id ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
// ─── Default / parse helpers ───────────────────────────────────────────────────
|
|
87
127
|
|
|
88
128
|
let stopCounter = 0;
|
|
@@ -356,9 +396,17 @@ export function GradientEditor({
|
|
|
356
396
|
};
|
|
357
397
|
|
|
358
398
|
// Bar background click → add a new stop (only if no significant drag movement).
|
|
399
|
+
// setPointerCapture pins all subsequent pointermove/pointerup events to the
|
|
400
|
+
// bar element regardless of where the cursor physically ends up — without
|
|
401
|
+
// it, a fast drag that leaves the bar's bounding box before the next
|
|
402
|
+
// pointermove fires stops delivering events to this handler (hit-testing
|
|
403
|
+
// sends them to whatever's now under the cursor instead), so the movement
|
|
404
|
+
// never gets flagged and the eventual pointerup elsewhere is misread as a
|
|
405
|
+
// stationary click that adds a spurious stop.
|
|
359
406
|
const handleBarPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
360
407
|
if (disabled) return;
|
|
361
408
|
barClickRef.current = { moved: false, startX: event.clientX };
|
|
409
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
362
410
|
};
|
|
363
411
|
|
|
364
412
|
const handleBarPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
@@ -369,6 +417,9 @@ export function GradientEditor({
|
|
|
369
417
|
};
|
|
370
418
|
|
|
371
419
|
const handleBarClick = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
420
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
421
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
422
|
+
}
|
|
372
423
|
// Only add if the pointer didn't move significantly (not a drag).
|
|
373
424
|
if (!barClickRef.current || barClickRef.current.moved) {
|
|
374
425
|
barClickRef.current = null;
|
|
@@ -440,10 +491,11 @@ export function GradientEditor({
|
|
|
440
491
|
|
|
441
492
|
const removeStop = (id: string) => {
|
|
442
493
|
if (value.stops.length <= 2) return;
|
|
494
|
+
const removed = value.stops.find((stop) => stop.id === id);
|
|
443
495
|
const nextStops = value.stops.filter((stop) => stop.id !== id);
|
|
444
496
|
onChange({ ...value, stops: nextStops });
|
|
445
497
|
if (selectedStopId === id) {
|
|
446
|
-
onSelectStop(
|
|
498
|
+
onSelectStop(nearestStopId(nextStops, removed?.position) ?? "");
|
|
447
499
|
}
|
|
448
500
|
};
|
|
449
501
|
|
|
@@ -454,6 +506,40 @@ export function GradientEditor({
|
|
|
454
506
|
const showAngle = value.kind === "linear" || value.kind === "angular";
|
|
455
507
|
const selectedStop = value.stops.find((s) => s.id === selectedStopId);
|
|
456
508
|
|
|
509
|
+
// Stop-position draft: buffered so typing doesn't commit (and dirty the
|
|
510
|
+
// history) on every keystroke. Commits on blur/Enter; Escape reverts.
|
|
511
|
+
const [positionDraft, setPositionDraft] = useState<string>(() =>
|
|
512
|
+
String(Math.round(selectedStop?.position ?? 0)),
|
|
513
|
+
);
|
|
514
|
+
const positionDraftRef = useRef(positionDraft);
|
|
515
|
+
const skipPositionBlurRef = useRef(false);
|
|
516
|
+
const selectedStopPosition = selectedStop?.position;
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
const next = String(Math.round(selectedStopPosition ?? 0));
|
|
519
|
+
positionDraftRef.current = next;
|
|
520
|
+
setPositionDraft(next);
|
|
521
|
+
// Resync whenever the selected stop changes or its position changes
|
|
522
|
+
// externally (drag on the bar, another control).
|
|
523
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
524
|
+
}, [selectedStopId, selectedStopPosition]);
|
|
525
|
+
|
|
526
|
+
const commitPositionDraft = () => {
|
|
527
|
+
const parsed = parseStopPositionDraft(positionDraftRef.current);
|
|
528
|
+
if (parsed === null) {
|
|
529
|
+
const reverted = String(Math.round(selectedStop?.position ?? 0));
|
|
530
|
+
positionDraftRef.current = reverted;
|
|
531
|
+
setPositionDraft(reverted);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
updateStopPosition(selectedStopId, parsed);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const revertPositionDraft = () => {
|
|
538
|
+
const reverted = String(Math.round(selectedStop?.position ?? 0));
|
|
539
|
+
positionDraftRef.current = reverted;
|
|
540
|
+
setPositionDraft(reverted);
|
|
541
|
+
};
|
|
542
|
+
|
|
457
543
|
return (
|
|
458
544
|
<div className={cn("px-3 pt-1.5 pb-1 select-none", className)}>
|
|
459
545
|
{/* ── Gradient bar + stop handles ──────────────────────────────────────── */}
|
|
@@ -558,11 +644,31 @@ export function GradientEditor({
|
|
|
558
644
|
max={100}
|
|
559
645
|
aria-label={"Stop position" /* i18n-ignore */}
|
|
560
646
|
disabled={disabled}
|
|
561
|
-
value={
|
|
647
|
+
value={positionDraft}
|
|
562
648
|
onChange={(event) => {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
649
|
+
positionDraftRef.current = event.target.value;
|
|
650
|
+
setPositionDraft(event.target.value);
|
|
651
|
+
}}
|
|
652
|
+
onFocus={(event) => event.target.select()}
|
|
653
|
+
onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
654
|
+
if (event.key === "Enter") {
|
|
655
|
+
event.preventDefault();
|
|
656
|
+
commitPositionDraft();
|
|
657
|
+
skipPositionBlurRef.current = true;
|
|
658
|
+
event.currentTarget.blur();
|
|
659
|
+
}
|
|
660
|
+
if (event.key === "Escape") {
|
|
661
|
+
revertPositionDraft();
|
|
662
|
+
skipPositionBlurRef.current = true;
|
|
663
|
+
event.currentTarget.blur();
|
|
664
|
+
}
|
|
665
|
+
}}
|
|
666
|
+
onBlur={() => {
|
|
667
|
+
if (skipPositionBlurRef.current) {
|
|
668
|
+
skipPositionBlurRef.current = false;
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
commitPositionDraft();
|
|
566
672
|
}}
|
|
567
673
|
className="h-full min-w-0 flex-1 bg-transparent px-1.5 !text-[11px] tabular-nums focus-visible:outline-none"
|
|
568
674
|
/>
|