@agent-native/core 0.80.0 → 0.80.1
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 +2 -2
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +14 -1
- package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/de-DE/template-design.mdx +15 -1
- package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/es-ES/template-design.mdx +15 -1
- package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +15 -1
- package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +15 -1
- package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +16 -1
- package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +14 -1
- package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +14 -1
- package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +14 -1
- package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +14 -1
- package/corpus/core/docs/content/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/template-design.mdx +14 -1
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/application-state/handlers.ts +1 -1
- package/corpus/core/src/brand-kit/index.ts +1 -1
- package/corpus/core/src/cli/skills.ts +18 -0
- package/corpus/core/src/client/AgentPanel.tsx +1 -1
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +1 -1
- package/corpus/core/src/client/extensions/portable-extension.ts +1 -1
- package/corpus/core/src/code-agents/transcript-normalizer.ts +1 -1
- package/corpus/core/src/extensions/path.ts +1 -4
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +1 -1
- package/corpus/core/src/mcp/build-server.ts +1 -1
- package/corpus/core/src/org/handlers.ts +5 -5
- package/corpus/core/src/org/plugin.ts +2 -2
- package/corpus/core/src/scripts/call-agent.ts +1 -1
- package/corpus/core/src/scripts/db/safety.ts +1 -1
- package/corpus/core/src/server/auth.ts +3 -0
- package/corpus/core/src/server/builder-design-systems.ts +308 -0
- package/corpus/core/src/server/core-routes-plugin.ts +29 -3
- package/corpus/core/src/server/index.ts +8 -0
- package/corpus/core/src/styles/agent-native.css +14 -7
- package/corpus/core/src/usage/store.ts +2 -2
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +298 -15
- package/corpus/templates/analytics/actions/get-sql-dashboard.ts +9 -4
- package/corpus/templates/analytics/actions/mutate-dashboard.ts +18 -2
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +12 -2
- package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +66 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +37 -0
- package/corpus/templates/analytics/app/i18n-data.ts +404 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +333 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +121 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +49 -26
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +137 -36
- package/corpus/templates/analytics/changelog/2026-06-29-analytics-agents-can-now-place-new-charts-into-requested-das.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-analytics-queries-now-report-failed-source-query-errors-inst.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-charts-can-now-be-dropped-between-full-dashboard-rows-to-cre.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-dashboard-email-reports-include-the-full-dashboard-image-wit.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-main-surface-border-reaches-the-top.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-session-replay-now-records-playable-sessions-in-production-a.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-set-up-session-replay-storage-from-settings-connect-builder-.md +6 -0
- package/corpus/templates/analytics/scripts/cleanup-empty-session-recordings.ts +102 -0
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +49 -26
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +81 -20
- package/corpus/templates/analytics/server/lib/replay-storage.ts +79 -0
- package/corpus/templates/analytics/server/lib/s3-upload-provider.ts +255 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +29 -8
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +11 -2
- package/corpus/templates/analytics/server/plugins/onboarding.ts +97 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +24 -0
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +105 -5
- package/corpus/templates/content/actions/set-image-alt-text.ts +1 -1
- package/corpus/templates/content/shared/document-export.ts +1 -1
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -18
- package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +17 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +32 -5
- package/corpus/templates/design/actions/index-design-system-with-builder.ts +259 -0
- package/corpus/templates/design/actions/update-file.ts +9 -2
- package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +33 -75
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +72 -9
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +41 -39
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1029 -249
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +450 -101
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +978 -154
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +7 -9
- package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +133 -66
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +1234 -344
- package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +144 -94
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +301 -72
- package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +876 -286
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +601 -0
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +242 -0
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +30 -7
- package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +212 -155
- package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +423 -0
- package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +487 -107
- package/corpus/templates/design/app/components/design/inspector/index.ts +27 -0
- package/corpus/templates/design/app/global.css +29 -3
- package/corpus/templates/design/app/i18n/zh-TW.ts +13 -0
- package/corpus/templates/design/app/i18n-data.ts +239 -1
- package/corpus/templates/design/app/pages/DesignEditor.tsx +1023 -353
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +57 -221
- package/corpus/templates/design/changelog/2026-06-29-canvas-selection-no-longer-flashes-broken-prototype-html-whe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-command-menu-selections-are-clearer-in-dark-mode.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-mode-top-chrome-now-groups-collaborators-preview-shar.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-element-selection-now-stays-on-the-intended-layer-after-sele.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-full-view-controls-now-sit-above-frames-inst.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-tools-can-draw-shapes-create-screens-and-kee.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-now-creates-anchor-based-bezier-vectors-with-ha.md +6 -0
- package/corpus/templates/design/server/handlers/import-figma-system.ts +38 -85
- package/corpus/templates/design/server/lib/design-snapshot.ts +9 -1
- package/corpus/templates/design/shared/code-layer.ts +209 -9
- package/corpus/templates/design/shared/html-content.ts +21 -0
- package/corpus/templates/design/shared/pen-path.ts +207 -0
- package/corpus/templates/mail/app/lib/utils.ts +2 -2
- package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +9 -10
- package/corpus/templates/slides/AGENTS.md +3 -2
- package/corpus/templates/slides/actions/import-file.ts +26 -17
- package/corpus/templates/slides/actions/index-design-system-with-builder.ts +259 -0
- package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +35 -167
- package/corpus/templates/slides/app/components/design-system/fig-import-response.ts +9 -13
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -1
- package/corpus/templates/slides/app/i18n/ar-SA.ts +9 -37
- package/corpus/templates/slides/app/i18n/de-DE.ts +10 -25
- package/corpus/templates/slides/app/i18n/en-US.ts +9 -26
- package/corpus/templates/slides/app/i18n/es-ES.ts +10 -28
- package/corpus/templates/slides/app/i18n/fr-FR.ts +10 -28
- package/corpus/templates/slides/app/i18n/hi-IN.ts +9 -26
- package/corpus/templates/slides/app/i18n/ja-JP.ts +10 -22
- package/corpus/templates/slides/app/i18n/ko-KR.ts +9 -23
- package/corpus/templates/slides/app/i18n/pt-BR.ts +10 -28
- package/corpus/templates/slides/app/i18n/zh-CN.ts +9 -22
- package/corpus/templates/slides/app/i18n/zh-TW.ts +9 -22
- package/corpus/templates/slides/server/handlers/import-figma-system.ts +44 -12
- package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
- package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +1 -1
- package/dist/application-state/handlers.js +1 -1
- package/dist/application-state/handlers.js.map +1 -1
- package/dist/brand-kit/index.d.ts +1 -1
- package/dist/brand-kit/index.js +1 -1
- package/dist/brand-kit/index.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +18 -0
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AgentPanel.js +1 -1
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +1 -1
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/extensions/portable-extension.d.ts +1 -1
- package/dist/client/extensions/portable-extension.d.ts.map +1 -1
- package/dist/client/extensions/portable-extension.js.map +1 -1
- package/dist/code-agents/transcript-normalizer.js +1 -1
- package/dist/code-agents/transcript-normalizer.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/extensions/path.d.ts +1 -1
- package/dist/extensions/path.d.ts.map +1 -1
- package/dist/extensions/path.js.map +1 -1
- package/dist/integrations/a2a-continuation-processor.js +1 -1
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/mcp/build-server.d.ts +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/org/handlers.js +5 -5
- package/dist/org/handlers.js.map +1 -1
- package/dist/org/plugin.js +2 -2
- package/dist/org/plugin.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/scripts/call-agent.js +1 -1
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/scripts/db/safety.js +1 -1
- package/dist/scripts/db/safety.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +3 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/builder-design-systems.d.ts +28 -0
- package/dist/server/builder-design-systems.d.ts.map +1 -0
- package/dist/server/builder-design-systems.js +217 -0
- package/dist/server/builder-design-systems.js.map +1 -0
- package/dist/server/core-routes-plugin.d.ts +1 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +20 -3
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/styles/agent-native.css +14 -7
- package/dist/usage/store.js +2 -2
- package/dist/usage/store.js.map +1 -1
- package/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
- package/docs/content/locales/ar-SA/template-design.mdx +14 -1
- package/docs/content/locales/de-DE/skills-guide.mdx +5 -0
- package/docs/content/locales/de-DE/template-design.mdx +15 -1
- package/docs/content/locales/es-ES/skills-guide.mdx +5 -0
- package/docs/content/locales/es-ES/template-design.mdx +15 -1
- package/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
- package/docs/content/locales/fr-FR/template-design.mdx +15 -1
- package/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
- package/docs/content/locales/hi-IN/template-design.mdx +15 -1
- package/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
- package/docs/content/locales/ja-JP/template-design.mdx +16 -1
- package/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
- package/docs/content/locales/ko-KR/template-design.mdx +14 -1
- package/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
- package/docs/content/locales/pt-BR/template-design.mdx +14 -1
- package/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
- package/docs/content/locales/zh-CN/template-design.mdx +14 -1
- package/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
- package/docs/content/locales/zh-TW/template-design.mdx +14 -1
- package/docs/content/skills-guide.mdx +5 -0
- package/docs/content/template-design.mdx +14 -1
- package/package.json +1 -1
- package/corpus/templates/design/actions/import-figma-file.ts +0 -166
- package/corpus/templates/slides/server/lib/fig-design-system.ts +0 -110
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { parseCssColor, rgbaToCss } from "@shared/color-utils";
|
|
2
|
+
import { IconTrash } from "@tabler/icons-react";
|
|
3
|
+
import {
|
|
4
|
+
type PointerEvent as ReactPointerEvent,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type GradientKind = "linear" | "radial" | "angular" | "diamond";
|
|
14
|
+
|
|
15
|
+
export interface GradientStopValue {
|
|
16
|
+
id: string;
|
|
17
|
+
/** Any CSS color string. */
|
|
18
|
+
color: string;
|
|
19
|
+
/** 0–100 along the gradient axis. */
|
|
20
|
+
position: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GradientValue {
|
|
24
|
+
kind: GradientKind;
|
|
25
|
+
/** Angle in degrees — used by linear and angular (conic) gradients. */
|
|
26
|
+
angle: number;
|
|
27
|
+
stops: GradientStopValue[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Checkerboard (matches FigmaColorPicker) ───────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const CHECKER_A = "#d4d4d4";
|
|
33
|
+
const CHECKERBOARD_IMAGE = `linear-gradient(45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(-45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${CHECKER_A} 75%), linear-gradient(-45deg, transparent 75%, ${CHECKER_A} 75%)`;
|
|
34
|
+
const CHECKER_SIZE = "8px 8px, 8px 8px, 8px 8px, 8px 8px";
|
|
35
|
+
const CHECKER_POS = "0 0, 0 4px, 4px -4px, -4px 0";
|
|
36
|
+
|
|
37
|
+
// ─── CSS serialization ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function sortedStops(stops: GradientStopValue[]): GradientStopValue[] {
|
|
40
|
+
return [...stops].sort((a, b) => a.position - b.position);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build a valid CSS gradient string for the given gradient value. */
|
|
44
|
+
export function gradientToCss(value: GradientValue): string {
|
|
45
|
+
const stops = sortedStops(value.stops)
|
|
46
|
+
.map((stop) => `${normalizeColor(stop.color)} ${round(stop.position)}%`)
|
|
47
|
+
.join(", ");
|
|
48
|
+
|
|
49
|
+
switch (value.kind) {
|
|
50
|
+
case "linear":
|
|
51
|
+
return `linear-gradient(${round(value.angle)}deg, ${stops})`;
|
|
52
|
+
case "radial":
|
|
53
|
+
return `radial-gradient(circle at center, ${stops})`;
|
|
54
|
+
case "diamond":
|
|
55
|
+
// CSS has no diamond gradient; a radial gradient with closest-side on a
|
|
56
|
+
// non-circular ellipse reads as the diamond falloff Figma shows.
|
|
57
|
+
return `radial-gradient(ellipse closest-side at center, ${stops})`;
|
|
58
|
+
case "angular":
|
|
59
|
+
return `conic-gradient(from ${round(value.angle)}deg at center, ${stops})`;
|
|
60
|
+
default:
|
|
61
|
+
return `linear-gradient(${round(value.angle)}deg, ${stops})`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A flat left-to-right preview of the stops, independent of kind/angle. */
|
|
66
|
+
function stopsBarCss(stops: GradientStopValue[]): string {
|
|
67
|
+
const ordered = sortedStops(stops)
|
|
68
|
+
.map((stop) => `${normalizeColor(stop.color)} ${round(stop.position)}%`)
|
|
69
|
+
.join(", ");
|
|
70
|
+
return `linear-gradient(90deg, ${ordered})`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeColor(color: string): string {
|
|
74
|
+
const parsed = parseCssColor(color);
|
|
75
|
+
return parsed ? rgbaToCss(parsed) : color;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function round(n: number): number {
|
|
79
|
+
return Math.round(n * 100) / 100;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clamp(n: number, min: number, max: number): number {
|
|
83
|
+
return Math.max(min, Math.min(max, n));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Default / parse helpers ───────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
let stopCounter = 0;
|
|
89
|
+
function nextStopId(): string {
|
|
90
|
+
stopCounter += 1;
|
|
91
|
+
return `gstop-${stopCounter}-${Math.random().toString(36).slice(2, 6)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function defaultGradient(
|
|
95
|
+
kind: GradientKind,
|
|
96
|
+
baseColor = "#000000",
|
|
97
|
+
): GradientValue {
|
|
98
|
+
const parsed = parseCssColor(baseColor);
|
|
99
|
+
const solid = parsed ? rgbaToCss({ ...parsed, a: 1 }) : "#000000";
|
|
100
|
+
const transparent = parsed
|
|
101
|
+
? rgbaToCss({ ...parsed, a: 0 })
|
|
102
|
+
: "rgba(0, 0, 0, 0)";
|
|
103
|
+
return {
|
|
104
|
+
kind,
|
|
105
|
+
angle: kind === "radial" || kind === "diamond" ? 0 : 90,
|
|
106
|
+
stops: [
|
|
107
|
+
{ id: nextStopId(), color: solid, position: 0 },
|
|
108
|
+
{ id: nextStopId(), color: transparent, position: 100 },
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const GRADIENT_FN_RE = /^(linear|radial|conic)-gradient\s*\(([\s\S]*)\)\s*$/i;
|
|
114
|
+
const ANGLE_RE = /(-?\d+(?:\.\d+)?)deg/;
|
|
115
|
+
// Split top-level commas (ignore commas inside rgb()/hsl() parens).
|
|
116
|
+
function splitTopLevel(input: string): string[] {
|
|
117
|
+
const parts: string[] = [];
|
|
118
|
+
let depth = 0;
|
|
119
|
+
let current = "";
|
|
120
|
+
for (const char of input) {
|
|
121
|
+
if (char === "(") depth += 1;
|
|
122
|
+
if (char === ")") depth -= 1;
|
|
123
|
+
if (char === "," && depth === 0) {
|
|
124
|
+
parts.push(current.trim());
|
|
125
|
+
current = "";
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
current += char;
|
|
129
|
+
}
|
|
130
|
+
if (current.trim()) parts.push(current.trim());
|
|
131
|
+
return parts;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Best-effort parse of a CSS gradient string back into a GradientValue. */
|
|
135
|
+
export function parseGradientCss(
|
|
136
|
+
value: string,
|
|
137
|
+
fallbackKind: GradientKind = "linear",
|
|
138
|
+
): GradientValue | null {
|
|
139
|
+
const match = value.trim().match(GRADIENT_FN_RE);
|
|
140
|
+
if (!match) return null;
|
|
141
|
+
|
|
142
|
+
const fn = match[1].toLowerCase();
|
|
143
|
+
const body = match[2];
|
|
144
|
+
const segments = splitTopLevel(body);
|
|
145
|
+
if (segments.length === 0) return null;
|
|
146
|
+
|
|
147
|
+
let kind: GradientKind = fallbackKind;
|
|
148
|
+
let angle = 90;
|
|
149
|
+
let stopStart = 0;
|
|
150
|
+
|
|
151
|
+
const first = segments[0];
|
|
152
|
+
const looksLikeStop =
|
|
153
|
+
/#|rgb|hsl|^\s*[a-z]+\s+\d/i.test(first) && fn === "linear"
|
|
154
|
+
? ANGLE_RE.test(first) === false && /%/.test(first)
|
|
155
|
+
: false;
|
|
156
|
+
|
|
157
|
+
if (fn === "linear") {
|
|
158
|
+
kind = fallbackKind === "linear" ? "linear" : fallbackKind;
|
|
159
|
+
const angleMatch = first.match(ANGLE_RE);
|
|
160
|
+
if (angleMatch) {
|
|
161
|
+
angle = Number(angleMatch[1]);
|
|
162
|
+
stopStart = 1;
|
|
163
|
+
} else if (/to\s+/i.test(first)) {
|
|
164
|
+
stopStart = 1;
|
|
165
|
+
} else if (!looksLikeStop && /^\s*(circle|ellipse|from|at)/i.test(first)) {
|
|
166
|
+
stopStart = 1;
|
|
167
|
+
}
|
|
168
|
+
} else if (fn === "radial") {
|
|
169
|
+
kind = /ellipse/i.test(first) ? "diamond" : "radial";
|
|
170
|
+
if (/circle|ellipse|at\s/i.test(first)) stopStart = 1;
|
|
171
|
+
} else if (fn === "conic") {
|
|
172
|
+
kind = "angular";
|
|
173
|
+
const angleMatch = first.match(ANGLE_RE);
|
|
174
|
+
if (angleMatch) angle = Number(angleMatch[1]);
|
|
175
|
+
if (/from|at\s/i.test(first)) stopStart = 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const stopSegments = segments.slice(stopStart);
|
|
179
|
+
const stops: GradientStopValue[] = [];
|
|
180
|
+
stopSegments.forEach((seg, index) => {
|
|
181
|
+
const posMatch = seg.match(/(-?\d+(?:\.\d+)?)%\s*$/);
|
|
182
|
+
const color = posMatch ? seg.slice(0, posMatch.index).trim() : seg.trim();
|
|
183
|
+
if (!color) return;
|
|
184
|
+
const position = posMatch
|
|
185
|
+
? clamp(Number(posMatch[1]), 0, 100)
|
|
186
|
+
: (index / Math.max(1, stopSegments.length - 1)) * 100;
|
|
187
|
+
stops.push({ id: nextStopId(), color, position });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (stops.length < 2) return null;
|
|
191
|
+
return { kind, angle, stops };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── AngleDial ────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
interface AngleDialProps {
|
|
197
|
+
angle: number;
|
|
198
|
+
onChange: (angle: number) => void;
|
|
199
|
+
disabled?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Figma-style circular dial for rotating gradient angle. */
|
|
203
|
+
function AngleDial({ angle, onChange, disabled = false }: AngleDialProps) {
|
|
204
|
+
const dialRef = useRef<HTMLDivElement>(null);
|
|
205
|
+
const draggingRef = useRef(false);
|
|
206
|
+
|
|
207
|
+
const angleFromPointer = (clientX: number, clientY: number): number => {
|
|
208
|
+
const rect = dialRef.current?.getBoundingClientRect();
|
|
209
|
+
if (!rect) return angle;
|
|
210
|
+
const cx = rect.left + rect.width / 2;
|
|
211
|
+
const cy = rect.top + rect.height / 2;
|
|
212
|
+
const rad = Math.atan2(clientY - cy, clientX - cx);
|
|
213
|
+
// atan2 gives angle from east; Figma's 0° is north (up), clockwise.
|
|
214
|
+
let deg = (rad * 180) / Math.PI + 90;
|
|
215
|
+
if (deg < 0) deg += 360;
|
|
216
|
+
if (deg >= 360) deg -= 360;
|
|
217
|
+
return Math.round(deg);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
221
|
+
if (disabled) return;
|
|
222
|
+
draggingRef.current = true;
|
|
223
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
224
|
+
onChange(angleFromPointer(e.clientX, e.clientY));
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
228
|
+
if (!draggingRef.current) return;
|
|
229
|
+
onChange(angleFromPointer(e.clientX, e.clientY));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const handlePointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
233
|
+
draggingRef.current = false;
|
|
234
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
235
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const dotAngle = ((angle - 90) * Math.PI) / 180;
|
|
240
|
+
// Dot placed at ~65% radius from center.
|
|
241
|
+
const r = 7;
|
|
242
|
+
const dotX = 50 + r * Math.cos(dotAngle);
|
|
243
|
+
const dotY = 50 + r * Math.sin(dotAngle);
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div
|
|
247
|
+
ref={dialRef}
|
|
248
|
+
role="slider"
|
|
249
|
+
aria-label={"Gradient angle" /* i18n-ignore */}
|
|
250
|
+
aria-valuenow={Math.round(angle)}
|
|
251
|
+
aria-valuemin={0}
|
|
252
|
+
aria-valuemax={360}
|
|
253
|
+
tabIndex={disabled ? -1 : 0}
|
|
254
|
+
onPointerDown={handlePointerDown}
|
|
255
|
+
onPointerMove={handlePointerMove}
|
|
256
|
+
onPointerUp={handlePointerUp}
|
|
257
|
+
onPointerCancel={handlePointerUp}
|
|
258
|
+
onKeyDown={(e) => {
|
|
259
|
+
if (disabled) return;
|
|
260
|
+
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
onChange((angle + 1) % 360);
|
|
263
|
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
onChange((angle - 1 + 360) % 360);
|
|
266
|
+
}
|
|
267
|
+
}}
|
|
268
|
+
className={cn(
|
|
269
|
+
"relative flex size-[18px] shrink-0 cursor-pointer select-none items-center justify-center rounded-full",
|
|
270
|
+
"border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]",
|
|
271
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
272
|
+
disabled && "pointer-events-none opacity-40",
|
|
273
|
+
)}
|
|
274
|
+
>
|
|
275
|
+
<svg
|
|
276
|
+
viewBox="0 0 100 100"
|
|
277
|
+
className="absolute inset-0 size-full"
|
|
278
|
+
aria-hidden="true"
|
|
279
|
+
>
|
|
280
|
+
{/* Outer ring track */}
|
|
281
|
+
<circle
|
|
282
|
+
cx="50"
|
|
283
|
+
cy="50"
|
|
284
|
+
r="38"
|
|
285
|
+
fill="none"
|
|
286
|
+
stroke="currentColor"
|
|
287
|
+
strokeWidth="0"
|
|
288
|
+
opacity="0"
|
|
289
|
+
/>
|
|
290
|
+
{/* Dot indicator */}
|
|
291
|
+
<circle
|
|
292
|
+
cx={dotX}
|
|
293
|
+
cy={dotY}
|
|
294
|
+
r="12"
|
|
295
|
+
fill="currentColor"
|
|
296
|
+
className="text-foreground"
|
|
297
|
+
/>
|
|
298
|
+
</svg>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
export interface GradientEditorProps {
|
|
306
|
+
value: GradientValue;
|
|
307
|
+
onChange: (value: GradientValue) => void;
|
|
308
|
+
selectedStopId: string;
|
|
309
|
+
onSelectStop: (id: string) => void;
|
|
310
|
+
disabled?: boolean;
|
|
311
|
+
className?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Stop handle dimensions — Figma uses ~12px handles with white ring.
|
|
315
|
+
const STOP_SIZE = 12; // px, the colored circle diameter
|
|
316
|
+
const STOP_RING = 2; // px, white border thickness
|
|
317
|
+
const STOP_OUTER = STOP_SIZE + STOP_RING * 2; // 16px total outer
|
|
318
|
+
const BAR_HEIGHT = 16; // px — the gradient preview bar
|
|
319
|
+
// Handles sit below the bar with a 2px notch gap.
|
|
320
|
+
const HANDLE_AREA = STOP_OUTER + 4; // px — vertical space for handles below bar
|
|
321
|
+
const WRAPPER_HEIGHT = BAR_HEIGHT + HANDLE_AREA; // total component height
|
|
322
|
+
|
|
323
|
+
export function GradientEditor({
|
|
324
|
+
value,
|
|
325
|
+
onChange,
|
|
326
|
+
selectedStopId,
|
|
327
|
+
onSelectStop,
|
|
328
|
+
disabled = false,
|
|
329
|
+
className,
|
|
330
|
+
}: GradientEditorProps) {
|
|
331
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
332
|
+
const draggingStopRef = useRef<string | null>(null);
|
|
333
|
+
// Track whether a pointerdown on the bar started a drag (vs click-to-add).
|
|
334
|
+
const barClickRef = useRef<{ moved: boolean; startX: number } | null>(null);
|
|
335
|
+
|
|
336
|
+
const [angleInput, setAngleInput] = useState<string | null>(null);
|
|
337
|
+
|
|
338
|
+
const positionFromPointer = (clientX: number): number => {
|
|
339
|
+
const rect = barRef.current?.getBoundingClientRect();
|
|
340
|
+
if (!rect) return 0;
|
|
341
|
+
return clamp(((clientX - rect.left) / rect.width) * 100, 0, 100);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const updateStopPosition = (id: string, position: number) => {
|
|
345
|
+
onChange({
|
|
346
|
+
...value,
|
|
347
|
+
stops: value.stops.map((stop) =>
|
|
348
|
+
stop.id === id ? { ...stop, position } : stop,
|
|
349
|
+
),
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Bar background click → add a new stop (only if no significant drag movement).
|
|
354
|
+
const handleBarPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
355
|
+
if (disabled) return;
|
|
356
|
+
barClickRef.current = { moved: false, startX: event.clientX };
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const handleBarPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
360
|
+
if (!barClickRef.current) return;
|
|
361
|
+
if (Math.abs(event.clientX - barClickRef.current.startX) > 3) {
|
|
362
|
+
barClickRef.current.moved = true;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const handleBarClick = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
367
|
+
// Only add if the pointer didn't move significantly (not a drag).
|
|
368
|
+
if (!barClickRef.current || barClickRef.current.moved) {
|
|
369
|
+
barClickRef.current = null;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
barClickRef.current = null;
|
|
373
|
+
const position = positionFromPointer(event.clientX);
|
|
374
|
+
const ordered = sortedStops(value.stops);
|
|
375
|
+
// Interpolate the color from the nearest stops for a natural insert.
|
|
376
|
+
const before = [...ordered].reverse().find((s) => s.position <= position);
|
|
377
|
+
const newColor = before?.color ?? ordered[0]?.color ?? "#000000";
|
|
378
|
+
const newStop: GradientStopValue = {
|
|
379
|
+
id: nextStopId(),
|
|
380
|
+
color: newColor,
|
|
381
|
+
position,
|
|
382
|
+
};
|
|
383
|
+
onChange({ ...value, stops: [...value.stops, newStop] });
|
|
384
|
+
onSelectStop(newStop.id);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const startStopDrag = (
|
|
388
|
+
event: ReactPointerEvent<HTMLButtonElement>,
|
|
389
|
+
id: string,
|
|
390
|
+
) => {
|
|
391
|
+
if (disabled) return;
|
|
392
|
+
event.stopPropagation();
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
onSelectStop(id);
|
|
395
|
+
draggingStopRef.current = id;
|
|
396
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const handleStopPointerMove = (
|
|
400
|
+
event: ReactPointerEvent<HTMLButtonElement>,
|
|
401
|
+
) => {
|
|
402
|
+
if (!draggingStopRef.current || disabled) return;
|
|
403
|
+
updateStopPosition(
|
|
404
|
+
draggingStopRef.current,
|
|
405
|
+
positionFromPointer(event.clientX),
|
|
406
|
+
);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const endStopDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
|
410
|
+
draggingStopRef.current = null;
|
|
411
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
412
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const removeStop = (id: string) => {
|
|
417
|
+
if (value.stops.length <= 2) return;
|
|
418
|
+
const nextStops = value.stops.filter((stop) => stop.id !== id);
|
|
419
|
+
onChange({ ...value, stops: nextStops });
|
|
420
|
+
if (selectedStopId === id) {
|
|
421
|
+
onSelectStop(sortedStops(nextStops)[0]?.id ?? "");
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const setAngle = (angle: number) => {
|
|
426
|
+
onChange({ ...value, angle: ((angle % 360) + 360) % 360 });
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const showAngle = value.kind === "linear" || value.kind === "angular";
|
|
430
|
+
const selectedStop = value.stops.find((s) => s.id === selectedStopId);
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<div className={cn("px-3 pt-1.5 pb-1 select-none", className)}>
|
|
434
|
+
{/* ── Gradient bar + stop handles ──────────────────────────────────────── */}
|
|
435
|
+
<div
|
|
436
|
+
className="relative"
|
|
437
|
+
style={{ height: WRAPPER_HEIGHT }}
|
|
438
|
+
onPointerMove={handleBarPointerMove}
|
|
439
|
+
>
|
|
440
|
+
{/* Checkerboard underlay */}
|
|
441
|
+
<div
|
|
442
|
+
className="absolute left-0 right-0 top-0 rounded-md"
|
|
443
|
+
style={{
|
|
444
|
+
height: BAR_HEIGHT,
|
|
445
|
+
backgroundImage: CHECKERBOARD_IMAGE,
|
|
446
|
+
backgroundSize: CHECKER_SIZE,
|
|
447
|
+
backgroundPosition: CHECKER_POS,
|
|
448
|
+
}}
|
|
449
|
+
aria-hidden="true"
|
|
450
|
+
/>
|
|
451
|
+
{/* Gradient bar — clicking empty area adds a stop */}
|
|
452
|
+
<div
|
|
453
|
+
ref={barRef}
|
|
454
|
+
role="group"
|
|
455
|
+
aria-label={"Gradient stops" /* i18n-ignore */}
|
|
456
|
+
onPointerDown={handleBarPointerDown}
|
|
457
|
+
onPointerUp={(e) => handleBarClick(e)}
|
|
458
|
+
className={cn(
|
|
459
|
+
"absolute left-0 right-0 top-0 cursor-copy rounded-md border border-border/50",
|
|
460
|
+
disabled && "cursor-not-allowed opacity-60",
|
|
461
|
+
)}
|
|
462
|
+
style={{
|
|
463
|
+
height: BAR_HEIGHT,
|
|
464
|
+
backgroundImage: stopsBarCss(value.stops),
|
|
465
|
+
}}
|
|
466
|
+
/>
|
|
467
|
+
|
|
468
|
+
{/* Stop handles — positioned below the bar */}
|
|
469
|
+
{value.stops.map((stop) => {
|
|
470
|
+
const isSelected = stop.id === selectedStopId;
|
|
471
|
+
const parsed = parseCssColor(stop.color);
|
|
472
|
+
// Opaque version for the handle swatch.
|
|
473
|
+
const solidColor = parsed
|
|
474
|
+
? rgbaToCss({ ...parsed, a: 1 })
|
|
475
|
+
: stop.color;
|
|
476
|
+
// Position the handle horizontally along the bar width.
|
|
477
|
+
// Handles sit 2px below the bar's bottom edge.
|
|
478
|
+
const topOffset = BAR_HEIGHT + 2;
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<button
|
|
482
|
+
key={stop.id}
|
|
483
|
+
type="button"
|
|
484
|
+
aria-label={`${stop.color} at ${Math.round(stop.position)}%`}
|
|
485
|
+
aria-pressed={isSelected}
|
|
486
|
+
disabled={disabled}
|
|
487
|
+
onPointerDown={(e) => startStopDrag(e, stop.id)}
|
|
488
|
+
onPointerMove={handleStopPointerMove}
|
|
489
|
+
onPointerUp={endStopDrag}
|
|
490
|
+
onPointerCancel={endStopDrag}
|
|
491
|
+
onClick={(e) => {
|
|
492
|
+
e.stopPropagation();
|
|
493
|
+
onSelectStop(stop.id);
|
|
494
|
+
}}
|
|
495
|
+
onDoubleClick={(e) => {
|
|
496
|
+
e.stopPropagation();
|
|
497
|
+
removeStop(stop.id);
|
|
498
|
+
}}
|
|
499
|
+
className={cn(
|
|
500
|
+
// Base: round handle with white border ring + outer accent ring
|
|
501
|
+
"absolute cursor-grab active:cursor-grabbing",
|
|
502
|
+
"rounded-full border-[2px] border-white",
|
|
503
|
+
"focus-visible:outline-none",
|
|
504
|
+
// Selected: accent-colored outer ring (like Figma's blue ring)
|
|
505
|
+
isSelected
|
|
506
|
+
? "shadow-[0_0_0_1.5px_var(--primary),0_1px_3px_rgba(0,0,0,0.35)]"
|
|
507
|
+
: "shadow-[0_0_0_1px_rgba(0,0,0,0.25),0_1px_3px_rgba(0,0,0,0.25)]",
|
|
508
|
+
)}
|
|
509
|
+
style={{
|
|
510
|
+
width: STOP_OUTER,
|
|
511
|
+
height: STOP_OUTER,
|
|
512
|
+
left: `${stop.position}%`,
|
|
513
|
+
top: topOffset,
|
|
514
|
+
// Center horizontally on the position %.
|
|
515
|
+
transform: "translateX(-50%)",
|
|
516
|
+
backgroundColor: solidColor,
|
|
517
|
+
}}
|
|
518
|
+
/>
|
|
519
|
+
);
|
|
520
|
+
})}
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
{/* ── Controls row: position, angle, remove ──────────────────────────── */}
|
|
524
|
+
<div className="mt-2 flex items-center gap-1">
|
|
525
|
+
{/* Selected stop position % */}
|
|
526
|
+
<div className="flex h-6 flex-1 items-center overflow-hidden rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]">
|
|
527
|
+
<span className="flex w-7 shrink-0 items-center justify-center border-r border-border/60 text-[10px] text-muted-foreground">
|
|
528
|
+
{"%" /* i18n-ignore */}
|
|
529
|
+
</span>
|
|
530
|
+
<input
|
|
531
|
+
type="number"
|
|
532
|
+
min={0}
|
|
533
|
+
max={100}
|
|
534
|
+
aria-label={"Stop position" /* i18n-ignore */}
|
|
535
|
+
disabled={disabled}
|
|
536
|
+
value={Math.round(selectedStop?.position ?? 0)}
|
|
537
|
+
onChange={(event) => {
|
|
538
|
+
const next = Number(event.target.value);
|
|
539
|
+
if (Number.isFinite(next))
|
|
540
|
+
updateStopPosition(selectedStopId, clamp(next, 0, 100));
|
|
541
|
+
}}
|
|
542
|
+
className="h-full min-w-0 flex-1 bg-transparent px-1.5 text-[11px] tabular-nums focus-visible:outline-none"
|
|
543
|
+
/>
|
|
544
|
+
</div>
|
|
545
|
+
|
|
546
|
+
{/* Angle: rotatable dial + numeric input */}
|
|
547
|
+
{showAngle && (
|
|
548
|
+
<div className="flex items-center gap-0.5">
|
|
549
|
+
<AngleDial
|
|
550
|
+
angle={value.angle}
|
|
551
|
+
onChange={setAngle}
|
|
552
|
+
disabled={disabled}
|
|
553
|
+
/>
|
|
554
|
+
<div className="flex h-6 w-14 items-center overflow-hidden rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]">
|
|
555
|
+
<input
|
|
556
|
+
type="number"
|
|
557
|
+
min={0}
|
|
558
|
+
max={360}
|
|
559
|
+
aria-label={"Gradient angle" /* i18n-ignore */}
|
|
560
|
+
disabled={disabled}
|
|
561
|
+
value={angleInput ?? Math.round(value.angle)}
|
|
562
|
+
onChange={(e) => {
|
|
563
|
+
setAngleInput(e.target.value);
|
|
564
|
+
const next = Number(e.target.value);
|
|
565
|
+
if (Number.isFinite(next)) setAngle(next);
|
|
566
|
+
}}
|
|
567
|
+
onBlur={() => setAngleInput(null)}
|
|
568
|
+
className="h-full min-w-0 flex-1 bg-transparent px-1.5 text-[11px] tabular-nums focus-visible:outline-none"
|
|
569
|
+
/>
|
|
570
|
+
<span className="flex w-4 shrink-0 items-center justify-center text-[10px] text-muted-foreground">
|
|
571
|
+
°
|
|
572
|
+
</span>
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
|
|
577
|
+
{/* Delete selected stop */}
|
|
578
|
+
<button
|
|
579
|
+
type="button"
|
|
580
|
+
disabled={disabled || value.stops.length <= 2}
|
|
581
|
+
aria-label={"Remove stop" /* i18n-ignore */}
|
|
582
|
+
onClick={() => removeStop(selectedStopId)}
|
|
583
|
+
className={cn(
|
|
584
|
+
"flex size-6 shrink-0 items-center justify-center rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-muted-foreground",
|
|
585
|
+
"hover:border-destructive/40 hover:text-destructive",
|
|
586
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
587
|
+
(disabled || value.stops.length <= 2) &&
|
|
588
|
+
"pointer-events-none opacity-40",
|
|
589
|
+
)}
|
|
590
|
+
>
|
|
591
|
+
<IconTrash className="size-3" />
|
|
592
|
+
</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Re-export the keep-stable counter reset for tests if ever needed.
|
|
599
|
+
export function __resetStopCounterForTest(): void {
|
|
600
|
+
stopCounter = 0;
|
|
601
|
+
}
|