entangle-ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/esm/assets/src/components/primitives/EditableText/EditableText.css.ts.vanilla-CrVGgD19.css +63 -0
  3. package/dist/esm/components/editor/ViewportGizmo/ViewportGizmo.js +3 -1
  4. package/dist/esm/components/editor/ViewportGizmo/ViewportGizmo.js.map +1 -1
  5. package/dist/esm/components/editor/ViewportGizmo/useGizmoInteraction.js +21 -27
  6. package/dist/esm/components/editor/ViewportGizmo/useGizmoInteraction.js.map +1 -1
  7. package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbEllipsis.js +1 -0
  8. package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbEllipsis.js.map +1 -1
  9. package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbItem.js +1 -0
  10. package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbItem.js.map +1 -1
  11. package/dist/esm/components/navigation/PathBar/PathBarSiblingMenu.js +1 -0
  12. package/dist/esm/components/navigation/PathBar/PathBarSiblingMenu.js.map +1 -1
  13. package/dist/esm/components/primitives/EditableText/EditableText.css.js +11 -0
  14. package/dist/esm/components/primitives/EditableText/EditableText.css.js.map +1 -0
  15. package/dist/esm/components/primitives/EditableText/EditableText.js +208 -0
  16. package/dist/esm/components/primitives/EditableText/EditableText.js.map +1 -0
  17. package/dist/esm/components/primitives/EditableText/editableTextLabels.js +16 -0
  18. package/dist/esm/components/primitives/EditableText/editableTextLabels.js.map +1 -0
  19. package/dist/esm/index.js +2 -0
  20. package/dist/esm/index.js.map +1 -1
  21. package/dist/tokens/tokens.dark.css +1 -1
  22. package/dist/tokens/tokens.json +1 -1
  23. package/dist/tokens/tokens.light.css +1 -1
  24. package/dist/types/components/editor/ViewportGizmo/ViewportGizmo.types.d.ts +26 -3
  25. package/dist/types/components/primitives/EditableText/EditableText.d.ts +37 -0
  26. package/dist/types/components/primitives/EditableText/EditableText.types.d.ts +158 -0
  27. package/dist/types/components/primitives/EditableText/editableTextLabels.d.ts +11 -0
  28. package/dist/types/index.d.ts +3 -0
  29. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # entangle-ui
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#114](https://github.com/SebastianWebdev/entangle-ui/pull/114) [`6594d8e`](https://github.com/SebastianWebdev/entangle-ui/commit/6594d8ef89d07e14e5322a1bd296f91dca1014d5) Thanks [@SebastianWebdev](https://github.com/SebastianWebdev)! - Add `EditableText`: a primitive that renders like `Text` but becomes a chrome-less inline editor on click — the editor-UI pattern for renaming layers, nodes, and assets in place. The idle state renders a real `<Text>` (inheriting every typography prop) and the editing state renders an `<input>` sharing the same typography recipe, so the swap is seamless; the field auto-sizes to its content with no measurement effects. Supports controlled/uncontrolled `value`, `activationMode` (`single` / `double`, plus Enter/F2 keyboard activation), commit on Enter/blur, cancel on Escape, `submitOnBlur` and `selectOnEdit` toggles, an imperative handle (`edit` / `commit` / `cancel` / `focus` / `isEditing` / `getElement`), full i18n via `labels` (`DEFAULT_EDITABLE_TEXT_LABELS`), and overridable `--etui-editable-text-*` custom properties.
8
+
9
+ - [#112](https://github.com/SebastianWebdev/entangle-ui/pull/112) [`4fc41c6`](https://github.com/SebastianWebdev/entangle-ui/commit/4fc41c62a5ce8e6fa1d90f6772875b64a482a23c) Thanks [@SebastianWebdev](https://github.com/SebastianWebdev)! - ViewportGizmo: add `invertYaw` and `invertPitch` props to flip the orbit drag direction without negating the `onOrbit` delta by hand. Inversion is applied before `constrainPitch`, so pitch clamping stays correct at the poles. Also documents the `onOrbit` delta sign convention and adds a copy-paste Three.js / R3F `OrbitControls` integration recipe to the docs.
10
+
3
11
  ## 0.12.0
4
12
 
5
13
  ### Minor Changes
@@ -0,0 +1,63 @@
1
+ .EditableText_displayStyle__d2an010 {
2
+ display: inline-block;
3
+ max-width: 100%;
4
+ box-sizing: border-box;
5
+ padding-inline: 3px;
6
+ margin-inline: -3px;
7
+ border-radius: var(--etui-radius-sm);
8
+ outline: none;
9
+ cursor: text;
10
+ transition: background-color var(--etui-transition-fast);
11
+ }
12
+ .EditableText_displayStyle__d2an010:hover {
13
+ background-color: var(--etui-editable-text-hover-bg, var(--etui-color-surface-hover));
14
+ }
15
+ .EditableText_displayStyle__d2an010:focus-visible {
16
+ box-shadow: var(--etui-shadow-focus);
17
+ }
18
+ .EditableText_displayReadOnlyStyle__d2an011 {
19
+ cursor: default;
20
+ }
21
+ .EditableText_displayReadOnlyStyle__d2an011:hover {
22
+ background-color: transparent;
23
+ }
24
+ .EditableText_displayDisabledStyle__d2an012 {
25
+ cursor: not-allowed;
26
+ }
27
+ .EditableText_displayDisabledStyle__d2an012:hover {
28
+ background-color: transparent;
29
+ }
30
+ .EditableText_editWrapStyle__d2an013 {
31
+ display: inline-grid;
32
+ max-width: 100%;
33
+ box-sizing: border-box;
34
+ padding-inline: 3px;
35
+ margin-inline: -3px;
36
+ border-radius: var(--etui-radius-sm);
37
+ background-color: var(--etui-editable-text-edit-bg, var(--etui-color-bg-tertiary));
38
+ box-shadow: inset 0 0 0 1px var(--etui-editable-text-edit-ring, var(--etui-color-border-focus));
39
+ }
40
+ .EditableText_sharedCell__d2an014 {
41
+ grid-area: 1 / 1;
42
+ min-width: 1ch;
43
+ padding: 0;
44
+ margin: 0;
45
+ border: 0;
46
+ white-space: pre;
47
+ font: inherit;
48
+ }
49
+ .EditableText_sizerStyle__d2an015 {
50
+ visibility: hidden;
51
+ pointer-events: none;
52
+ user-select: none;
53
+ }
54
+ .EditableText_inputStyle__d2an016 {
55
+ width: 100%;
56
+ background: transparent;
57
+ outline: none;
58
+ color: inherit;
59
+ appearance: none;
60
+ }
61
+ .EditableText_inputStyle__d2an016::placeholder {
62
+ color: var(--etui-color-text-muted);
63
+ }
@@ -9,7 +9,7 @@ import { gizmoCanvasStyle, ariaLiveRegionStyle, gizmoDiameterVar, gizmoWrapperRe
9
9
 
10
10
  const DEFAULT_LABELS = { x: 'X', y: 'Y', z: 'Z' };
11
11
  const DEFAULT_VISIBLE = { x: true, y: true, z: true };
12
- const ViewportGizmo = ({ orientation, upAxis = 'y-up', axisColorPreset = 'blender', axisConfig, showLabels = true, showNegativeAxes = true, showOrbitRing = true, showOriginHandle = true, background = 'subtle', interactionMode = 'full', orbitSpeed = 1, constrainPitch = true, onOrbit, onOrbitEnd, onSnapToView, onAxisClick, onOriginClick, diameter = 120, size = 'md', disabled = false, className, testId, id, style, ...rest }) => {
12
+ const ViewportGizmo = ({ orientation, upAxis = 'y-up', axisColorPreset = 'blender', axisConfig, showLabels = true, showNegativeAxes = true, showOrbitRing = true, showOriginHandle = true, background = 'subtle', interactionMode = 'full', orbitSpeed = 1, constrainPitch = true, invertYaw = false, invertPitch = false, onOrbit, onOrbitEnd, onSnapToView, onAxisClick, onOriginClick, diameter = 120, size = 'md', disabled = false, className, testId, id, style, ...rest }) => {
13
13
  const canvasRef = useRef(null);
14
14
  // Resolve axis colors from preset or custom config
15
15
  const axisColors = useMemo(() => {
@@ -51,6 +51,8 @@ const ViewportGizmo = ({ orientation, upAxis = 'y-up', axisColorPreset = 'blende
51
51
  interactionMode,
52
52
  orbitSpeed,
53
53
  constrainPitch,
54
+ invertYaw,
55
+ invertPitch,
54
56
  disabled,
55
57
  onOrbit,
56
58
  onOrbitEnd,
@@ -1 +1 @@
1
- {"version":3,"file":"ViewportGizmo.js","sources":["src/components/editor/ViewportGizmo/ViewportGizmo.tsx"],"sourcesContent":["'use client';\n\nimport { assignInlineVars } from '@vanilla-extract/dynamic';\nimport React, { useRef, useMemo } from 'react';\n\nimport { cx } from '@/utils/cx';\n\nimport { useGizmoInteraction } from './useGizmoInteraction';\nimport { useGizmoRenderer, AXIS_COLORS } from './useGizmoRenderer';\nimport {\n gizmoWrapperRecipe,\n gizmoCanvasStyle,\n gizmoDiameterVar,\n ariaLiveRegionStyle,\n} from './ViewportGizmo.css';\n\nimport type { ViewportGizmoProps } from './ViewportGizmo.types';\n\nconst DEFAULT_LABELS: Record<string, string> = { x: 'X', y: 'Y', z: 'Z' };\nconst DEFAULT_VISIBLE: Record<string, boolean> = { x: true, y: true, z: true };\n\nexport const ViewportGizmo: React.FC<ViewportGizmoProps> = ({\n orientation,\n upAxis = 'y-up',\n axisColorPreset = 'blender',\n axisConfig,\n showLabels = true,\n showNegativeAxes = true,\n showOrbitRing = true,\n showOriginHandle = true,\n background = 'subtle',\n interactionMode = 'full',\n orbitSpeed = 1,\n constrainPitch = true,\n onOrbit,\n onOrbitEnd,\n onSnapToView,\n onAxisClick,\n onOriginClick,\n diameter = 120,\n size = 'md',\n disabled = false,\n className,\n testId,\n id,\n style,\n ...rest\n}) => {\n const canvasRef = useRef<HTMLCanvasElement>(null);\n\n // Resolve axis colors from preset or custom config\n const axisColors = useMemo((): Record<string, string> => {\n if (axisColorPreset === 'custom' && axisConfig) {\n return {\n x: axisConfig[0].color ?? '#E63946',\n y: axisConfig[1].color ?? '#6AA84F',\n z: axisConfig[2].color ?? '#4A86C8',\n };\n }\n return { ...AXIS_COLORS };\n }, [axisColorPreset, axisConfig]);\n\n const axisLabels = useMemo((): Record<string, string> => {\n if (axisConfig) {\n return {\n x: axisConfig[0].label ?? 'X',\n y: axisConfig[1].label ?? 'Y',\n z: axisConfig[2].label ?? 'Z',\n };\n }\n return DEFAULT_LABELS;\n }, [axisConfig]);\n\n const axisVisible = useMemo((): Record<string, boolean> => {\n if (axisConfig) {\n return {\n x: axisConfig[0].visible ?? true,\n y: axisConfig[1].visible ?? true,\n z: axisConfig[2].visible ?? true,\n };\n }\n return DEFAULT_VISIBLE;\n }, [axisConfig]);\n\n // Interaction\n const interaction = useGizmoInteraction({\n orientation,\n canvasRef,\n diameter,\n upAxis,\n interactionMode,\n orbitSpeed,\n constrainPitch,\n disabled,\n onOrbit,\n onOrbitEnd,\n onSnapToView,\n onAxisClick,\n onOriginClick,\n });\n\n // Renderer\n useGizmoRenderer({\n canvasRef,\n orientation,\n diameter,\n upAxis,\n size,\n axisColors,\n axisLabels,\n axisVisible,\n showLabels,\n showNegativeAxes,\n showOrbitRing,\n showOriginHandle,\n background,\n disabled,\n isDragging: interaction.isDragging,\n hoveredRegion: interaction.hoveredRegion,\n });\n\n // ARIA\n const ariaDescription = `3D orientation gizmo, yaw: ${Math.round(orientation.yaw)}deg, pitch: ${Math.round(orientation.pitch)}deg`;\n\n return (\n <div\n className={cx(gizmoWrapperRecipe({ background, disabled }), className)}\n style={{\n ...style,\n ...assignInlineVars({ [gizmoDiameterVar]: `${diameter}px` }),\n }}\n data-testid={testId}\n id={id}\n {...rest}\n >\n <canvas\n ref={canvasRef}\n className={gizmoCanvasStyle({ disabled })}\n // The canvas is a self-contained interactive widget driven entirely by\n // custom pointer/keyboard handlers, so it is exposed as an ARIA\n // application rather than its implicit canvas role.\n // eslint-disable-next-line jsx-a11y/no-interactive-element-to-noninteractive-role\n role=\"application\"\n aria-label=\"Viewport orientation gizmo\"\n aria-roledescription={ariaDescription}\n tabIndex={disabled ? -1 : 0}\n onPointerDown={interaction.handlers.onPointerDown}\n onPointerMove={interaction.handlers.onPointerMove}\n onPointerUp={interaction.handlers.onPointerUp}\n onKeyDown={interaction.handlers.onKeyDown}\n />\n <div className={ariaLiveRegionStyle} aria-live=\"polite\" role=\"status\">\n {ariaDescription}\n </div>\n </div>\n );\n};\n\nViewportGizmo.displayName = 'ViewportGizmo';\n"],"names":[],"mappings":";;;;;;;;;AAkBA;AACA;AAEO;AA2BL;;AAGA;AACE;;;;;;;AAOA;AACF;AAEA;;;;;;;;AAQE;AACF;AAEA;;;;;;;;AAQE;AACF;;;;;;;;;;;;;;;;AAiBC;;AAGD;;;;;;;;;;;;;;;;AAiBC;;;AAKD;AAIM;;;;;;;AAcA;AAcR;AAEA;;"}
1
+ {"version":3,"file":"ViewportGizmo.js","sources":["src/components/editor/ViewportGizmo/ViewportGizmo.tsx"],"sourcesContent":["'use client';\n\nimport { assignInlineVars } from '@vanilla-extract/dynamic';\nimport React, { useRef, useMemo } from 'react';\n\nimport { cx } from '@/utils/cx';\n\nimport { useGizmoInteraction } from './useGizmoInteraction';\nimport { useGizmoRenderer, AXIS_COLORS } from './useGizmoRenderer';\nimport {\n gizmoWrapperRecipe,\n gizmoCanvasStyle,\n gizmoDiameterVar,\n ariaLiveRegionStyle,\n} from './ViewportGizmo.css';\n\nimport type { ViewportGizmoProps } from './ViewportGizmo.types';\n\nconst DEFAULT_LABELS: Record<string, string> = { x: 'X', y: 'Y', z: 'Z' };\nconst DEFAULT_VISIBLE: Record<string, boolean> = { x: true, y: true, z: true };\n\nexport const ViewportGizmo: React.FC<ViewportGizmoProps> = ({\n orientation,\n upAxis = 'y-up',\n axisColorPreset = 'blender',\n axisConfig,\n showLabels = true,\n showNegativeAxes = true,\n showOrbitRing = true,\n showOriginHandle = true,\n background = 'subtle',\n interactionMode = 'full',\n orbitSpeed = 1,\n constrainPitch = true,\n invertYaw = false,\n invertPitch = false,\n onOrbit,\n onOrbitEnd,\n onSnapToView,\n onAxisClick,\n onOriginClick,\n diameter = 120,\n size = 'md',\n disabled = false,\n className,\n testId,\n id,\n style,\n ...rest\n}) => {\n const canvasRef = useRef<HTMLCanvasElement>(null);\n\n // Resolve axis colors from preset or custom config\n const axisColors = useMemo((): Record<string, string> => {\n if (axisColorPreset === 'custom' && axisConfig) {\n return {\n x: axisConfig[0].color ?? '#E63946',\n y: axisConfig[1].color ?? '#6AA84F',\n z: axisConfig[2].color ?? '#4A86C8',\n };\n }\n return { ...AXIS_COLORS };\n }, [axisColorPreset, axisConfig]);\n\n const axisLabels = useMemo((): Record<string, string> => {\n if (axisConfig) {\n return {\n x: axisConfig[0].label ?? 'X',\n y: axisConfig[1].label ?? 'Y',\n z: axisConfig[2].label ?? 'Z',\n };\n }\n return DEFAULT_LABELS;\n }, [axisConfig]);\n\n const axisVisible = useMemo((): Record<string, boolean> => {\n if (axisConfig) {\n return {\n x: axisConfig[0].visible ?? true,\n y: axisConfig[1].visible ?? true,\n z: axisConfig[2].visible ?? true,\n };\n }\n return DEFAULT_VISIBLE;\n }, [axisConfig]);\n\n // Interaction\n const interaction = useGizmoInteraction({\n orientation,\n canvasRef,\n diameter,\n upAxis,\n interactionMode,\n orbitSpeed,\n constrainPitch,\n invertYaw,\n invertPitch,\n disabled,\n onOrbit,\n onOrbitEnd,\n onSnapToView,\n onAxisClick,\n onOriginClick,\n });\n\n // Renderer\n useGizmoRenderer({\n canvasRef,\n orientation,\n diameter,\n upAxis,\n size,\n axisColors,\n axisLabels,\n axisVisible,\n showLabels,\n showNegativeAxes,\n showOrbitRing,\n showOriginHandle,\n background,\n disabled,\n isDragging: interaction.isDragging,\n hoveredRegion: interaction.hoveredRegion,\n });\n\n // ARIA\n const ariaDescription = `3D orientation gizmo, yaw: ${Math.round(orientation.yaw)}deg, pitch: ${Math.round(orientation.pitch)}deg`;\n\n return (\n <div\n className={cx(gizmoWrapperRecipe({ background, disabled }), className)}\n style={{\n ...style,\n ...assignInlineVars({ [gizmoDiameterVar]: `${diameter}px` }),\n }}\n data-testid={testId}\n id={id}\n {...rest}\n >\n <canvas\n ref={canvasRef}\n className={gizmoCanvasStyle({ disabled })}\n // The canvas is a self-contained interactive widget driven entirely by\n // custom pointer/keyboard handlers, so it is exposed as an ARIA\n // application rather than its implicit canvas role.\n // eslint-disable-next-line jsx-a11y/no-interactive-element-to-noninteractive-role\n role=\"application\"\n aria-label=\"Viewport orientation gizmo\"\n aria-roledescription={ariaDescription}\n tabIndex={disabled ? -1 : 0}\n onPointerDown={interaction.handlers.onPointerDown}\n onPointerMove={interaction.handlers.onPointerMove}\n onPointerUp={interaction.handlers.onPointerUp}\n onKeyDown={interaction.handlers.onKeyDown}\n />\n <div className={ariaLiveRegionStyle} aria-live=\"polite\" role=\"status\">\n {ariaDescription}\n </div>\n </div>\n );\n};\n\nViewportGizmo.displayName = 'ViewportGizmo';\n"],"names":[],"mappings":";;;;;;;;;AAkBA;AACA;;AA+BE;;AAGA;AACE;;;;;;;AAOA;AACF;AAEA;;;;;;;;AAQE;AACF;AAEA;;;;;;;;AAQE;AACF;;;;;;;;;;;;;;;;;;AAmBC;;AAGD;;;;;;;;;;;;;;;;AAiBC;;;AAKD;AAIM;;;;;;;AAcA;AAcR;AAEA;;"}
@@ -7,7 +7,7 @@ const NONE_HIT = { type: 'none', distance: Infinity };
7
7
  const ORBIT_KEY_STEP = 15;
8
8
  const ORBIT_FINE_STEP = 5;
9
9
  function useGizmoInteraction(options) {
10
- const { orientation, canvasRef, diameter, upAxis, interactionMode, orbitSpeed, constrainPitch, disabled, onOrbit, onOrbitEnd, onSnapToView, onAxisClick, onOriginClick, } = options;
10
+ const { orientation, canvasRef, diameter, upAxis, interactionMode, orbitSpeed, constrainPitch, invertYaw, invertPitch, disabled, onOrbit, onOrbitEnd, onSnapToView, onAxisClick, onOriginClick, } = options;
11
11
  const [hoveredRegion, setHoveredRegion] = useState(NONE_HIT);
12
12
  // Ref drives synchronous reads inside the pointer handlers; the state mirror
13
13
  // is the value exposed for rendering (kept in sync on drag start/end).
@@ -27,6 +27,19 @@ function useGizmoInteraction(options) {
27
27
  const rect = canvas.getBoundingClientRect();
28
28
  return { px: e.clientX - rect.left, py: e.clientY - rect.top };
29
29
  }, [canvasRef]);
30
+ // Single emission point for orbit deltas so the pointer and keyboard paths
31
+ // stay consistent. Inversion is applied last, then pitch is constrained
32
+ // against the value that will actually be applied — this keeps the
33
+ // constraint correct at both poles regardless of `invertPitch`.
34
+ const emitOrbit = useCallback((rawYaw, rawPitch) => {
35
+ const deltaYaw = invertYaw ? -rawYaw : rawYaw;
36
+ let deltaPitch = invertPitch ? -rawPitch : rawPitch;
37
+ if (constrainPitch) {
38
+ deltaPitch =
39
+ clamp(orientation.pitch + deltaPitch, -90, 90) - orientation.pitch;
40
+ }
41
+ onOrbit?.({ deltaYaw, deltaPitch });
42
+ }, [invertYaw, invertPitch, constrainPitch, orientation.pitch, onOrbit]);
30
43
  const onPointerDown = useCallback((e) => {
31
44
  if (disabled || interactionMode === 'display-only' || e.button !== 0)
32
45
  return;
@@ -67,13 +80,7 @@ function useGizmoInteraction(options) {
67
80
  hasDraggedRef.current = true;
68
81
  }
69
82
  if (hasDraggedRef.current) {
70
- const deltaYaw = dx * orbitSpeed * 0.5;
71
- let deltaPitch = -dy * orbitSpeed * 0.5;
72
- if (constrainPitch) {
73
- const newPitch = orientation.pitch + deltaPitch;
74
- deltaPitch = clamp(newPitch, -90, 90) - orientation.pitch;
75
- }
76
- onOrbit?.({ deltaYaw, deltaPitch });
83
+ emitOrbit(dx * orbitSpeed * 0.5, -dy * orbitSpeed * 0.5);
77
84
  }
78
85
  lastPointerRef.current = { x: e.clientX, y: e.clientY };
79
86
  return;
@@ -101,7 +108,7 @@ function useGizmoInteraction(options) {
101
108
  canvasRef,
102
109
  allowOrbit,
103
110
  orbitSpeed,
104
- constrainPitch,
111
+ emitOrbit,
105
112
  orientation,
106
113
  interactionMode,
107
114
  disabled,
@@ -109,7 +116,6 @@ function useGizmoInteraction(options) {
109
116
  center,
110
117
  armLength,
111
118
  upAxis,
112
- onOrbit,
113
119
  ]);
114
120
  const onPointerUp = useCallback((e) => {
115
121
  const canvas = canvasRef.current;
@@ -166,35 +172,25 @@ function useGizmoInteraction(options) {
166
172
  switch (e.key) {
167
173
  case 'ArrowLeft':
168
174
  if (allowOrbit) {
169
- onOrbit?.({ deltaYaw: -step, deltaPitch: 0 });
175
+ emitOrbit(-step, 0);
170
176
  handled = true;
171
177
  }
172
178
  break;
173
179
  case 'ArrowRight':
174
180
  if (allowOrbit) {
175
- onOrbit?.({ deltaYaw: step, deltaPitch: 0 });
181
+ emitOrbit(step, 0);
176
182
  handled = true;
177
183
  }
178
184
  break;
179
185
  case 'ArrowUp':
180
186
  if (allowOrbit) {
181
- let deltaPitch = step;
182
- if (constrainPitch) {
183
- deltaPitch =
184
- clamp(orientation.pitch + step, -90, 90) - orientation.pitch;
185
- }
186
- onOrbit?.({ deltaYaw: 0, deltaPitch });
187
+ emitOrbit(0, step);
187
188
  handled = true;
188
189
  }
189
190
  break;
190
191
  case 'ArrowDown':
191
192
  if (allowOrbit) {
192
- let deltaPitch = -step;
193
- if (constrainPitch) {
194
- deltaPitch =
195
- clamp(orientation.pitch - step, -90, 90) - orientation.pitch;
196
- }
197
- onOrbit?.({ deltaYaw: 0, deltaPitch });
193
+ emitOrbit(0, -step);
198
194
  handled = true;
199
195
  }
200
196
  break;
@@ -230,9 +226,7 @@ function useGizmoInteraction(options) {
230
226
  interactionMode,
231
227
  allowOrbit,
232
228
  allowSnap,
233
- constrainPitch,
234
- orientation,
235
- onOrbit,
229
+ emitOrbit,
236
230
  onSnapToView,
237
231
  onOriginClick,
238
232
  ]);
@@ -1 +1 @@
1
- {"version":3,"file":"useGizmoInteraction.js","sources":["src/components/editor/ViewportGizmo/useGizmoInteraction.ts"],"sourcesContent":["'use client';\n\nimport { useState, useCallback, useMemo, useRef } from 'react';\n\nimport { clamp } from '@/utils/mathUtils';\n\nimport { gizmoHitTest, axisToPresetView } from './gizmoMath';\n\nimport type {\n GizmoOrientation,\n GizmoUpAxis,\n GizmoPresetView,\n GizmoHitRegion,\n OrbitDelta,\n} from './ViewportGizmo.types';\nimport type React from 'react';\n\ninterface UseGizmoInteractionOptions {\n orientation: GizmoOrientation;\n canvasRef: React.RefObject<HTMLCanvasElement | null>;\n diameter: number;\n upAxis: GizmoUpAxis;\n interactionMode: 'full' | 'snap-only' | 'orbit-only' | 'display-only';\n orbitSpeed: number;\n constrainPitch: boolean;\n disabled: boolean;\n onOrbit?: (delta: OrbitDelta) => void;\n onOrbitEnd?: (finalOrientation: GizmoOrientation) => void;\n onSnapToView?: (view: GizmoPresetView) => void;\n onAxisClick?: (axis: 'x' | 'y' | 'z', positive: boolean) => void;\n onOriginClick?: () => void;\n}\n\ninterface UseGizmoInteractionReturn {\n isDragging: boolean;\n hoveredRegion: GizmoHitRegion;\n handlers: {\n onPointerDown: (e: React.PointerEvent) => void;\n onPointerMove: (e: React.PointerEvent) => void;\n onPointerUp: (e: React.PointerEvent) => void;\n onKeyDown: (e: React.KeyboardEvent) => void;\n };\n}\n\nconst NONE_HIT: GizmoHitRegion = { type: 'none', distance: Infinity };\n\nconst ORBIT_KEY_STEP = 15;\nconst ORBIT_FINE_STEP = 5;\n\nexport function useGizmoInteraction(\n options: UseGizmoInteractionOptions\n): UseGizmoInteractionReturn {\n const {\n orientation,\n canvasRef,\n diameter,\n upAxis,\n interactionMode,\n orbitSpeed,\n constrainPitch,\n disabled,\n onOrbit,\n onOrbitEnd,\n onSnapToView,\n onAxisClick,\n onOriginClick,\n } = options;\n\n const [hoveredRegion, setHoveredRegion] = useState<GizmoHitRegion>(NONE_HIT);\n // Ref drives synchronous reads inside the pointer handlers; the state mirror\n // is the value exposed for rendering (kept in sync on drag start/end).\n const isDraggingRef = useRef(false);\n const [isDragging, setIsDragging] = useState(false);\n const lastPointerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });\n const startPointerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });\n const hasDraggedRef = useRef(false);\n\n const allowSnap =\n interactionMode === 'full' || interactionMode === 'snap-only';\n const allowOrbit =\n interactionMode === 'full' || interactionMode === 'orbit-only';\n\n const armLength = (diameter / 2) * 0.65;\n const center = useMemo(\n () => ({ x: diameter / 2, y: diameter / 2 }),\n [diameter]\n );\n\n const getPointerPos = useCallback(\n (e: React.PointerEvent) => {\n const canvas = canvasRef.current;\n if (!canvas) return { px: 0, py: 0 };\n const rect = canvas.getBoundingClientRect();\n return { px: e.clientX - rect.left, py: e.clientY - rect.top };\n },\n [canvasRef]\n );\n\n const onPointerDown = useCallback(\n (e: React.PointerEvent) => {\n if (disabled || interactionMode === 'display-only' || e.button !== 0)\n return;\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const { px, py } = getPointerPos(e);\n\n isDraggingRef.current = true;\n hasDraggedRef.current = false;\n lastPointerRef.current = { x: e.clientX, y: e.clientY };\n startPointerRef.current = { x: e.clientX, y: e.clientY };\n setIsDragging(true);\n canvas.setPointerCapture(e.pointerId);\n\n // Store hit for click detection on pointer up\n const hit = gizmoHitTest(px, py, orientation, center, armLength, upAxis);\n setHoveredRegion(hit);\n },\n [\n disabled,\n interactionMode,\n canvasRef,\n getPointerPos,\n orientation,\n center,\n armLength,\n upAxis,\n ]\n );\n\n const onPointerMove = useCallback(\n (e: React.PointerEvent) => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n if (isDraggingRef.current && allowOrbit) {\n const dx = e.clientX - lastPointerRef.current.x;\n const dy = e.clientY - lastPointerRef.current.y;\n\n // Only count as drag if moved > 3px from start\n const totalDx = e.clientX - startPointerRef.current.x;\n const totalDy = e.clientY - startPointerRef.current.y;\n if (Math.sqrt(totalDx ** 2 + totalDy ** 2) > 3) {\n hasDraggedRef.current = true;\n }\n\n if (hasDraggedRef.current) {\n const deltaYaw = dx * orbitSpeed * 0.5;\n let deltaPitch = -dy * orbitSpeed * 0.5;\n\n if (constrainPitch) {\n const newPitch = orientation.pitch + deltaPitch;\n deltaPitch = clamp(newPitch, -90, 90) - orientation.pitch;\n }\n\n onOrbit?.({ deltaYaw, deltaPitch });\n }\n\n lastPointerRef.current = { x: e.clientX, y: e.clientY };\n return;\n }\n\n // Hover detection\n if (interactionMode === 'display-only') return;\n const { px, py } = getPointerPos(e);\n const hit = gizmoHitTest(px, py, orientation, center, armLength, upAxis);\n setHoveredRegion(hit);\n\n // Cursor\n if (disabled) {\n canvas.style.cursor = 'default';\n } else if (hit.type === 'axis-positive' || hit.type === 'origin') {\n canvas.style.cursor = 'pointer';\n } else if (allowOrbit) {\n canvas.style.cursor = isDraggingRef.current ? 'grabbing' : 'grab';\n } else {\n canvas.style.cursor = 'default';\n }\n },\n [\n canvasRef,\n allowOrbit,\n orbitSpeed,\n constrainPitch,\n orientation,\n interactionMode,\n disabled,\n getPointerPos,\n center,\n armLength,\n upAxis,\n onOrbit,\n ]\n );\n\n const onPointerUp = useCallback(\n (e: React.PointerEvent) => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n if (isDraggingRef.current) {\n isDraggingRef.current = false;\n setIsDragging(false);\n canvas.releasePointerCapture(e.pointerId);\n\n if (hasDraggedRef.current) {\n // Finished orbit drag\n onOrbitEnd?.(orientation);\n } else if (allowSnap) {\n // Click (no drag) — check hit\n const { px, py } = getPointerPos(e);\n const hit = gizmoHitTest(\n px,\n py,\n orientation,\n center,\n armLength,\n upAxis\n );\n\n if (hit.type === 'origin') {\n onOriginClick?.();\n } else if (hit.type === 'axis-positive' && hit.axis) {\n onAxisClick?.(hit.axis, true);\n const view = axisToPresetView(hit.axis, true, upAxis);\n if (view) onSnapToView?.(view);\n } else if (hit.type === 'axis-negative' && hit.axis) {\n onAxisClick?.(hit.axis, false);\n const view = axisToPresetView(hit.axis, false, upAxis);\n if (view) onSnapToView?.(view);\n }\n }\n }\n },\n [\n canvasRef,\n allowSnap,\n getPointerPos,\n orientation,\n center,\n armLength,\n upAxis,\n onOrbitEnd,\n onOriginClick,\n onAxisClick,\n onSnapToView,\n ]\n );\n\n const onKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (disabled || interactionMode === 'display-only') return;\n\n const fine = e.shiftKey;\n const step = fine ? ORBIT_FINE_STEP : ORBIT_KEY_STEP;\n let handled = false;\n\n switch (e.key) {\n case 'ArrowLeft':\n if (allowOrbit) {\n onOrbit?.({ deltaYaw: -step, deltaPitch: 0 });\n handled = true;\n }\n break;\n case 'ArrowRight':\n if (allowOrbit) {\n onOrbit?.({ deltaYaw: step, deltaPitch: 0 });\n handled = true;\n }\n break;\n case 'ArrowUp':\n if (allowOrbit) {\n let deltaPitch = step;\n if (constrainPitch) {\n deltaPitch =\n clamp(orientation.pitch + step, -90, 90) - orientation.pitch;\n }\n onOrbit?.({ deltaYaw: 0, deltaPitch });\n handled = true;\n }\n break;\n case 'ArrowDown':\n if (allowOrbit) {\n let deltaPitch = -step;\n if (constrainPitch) {\n deltaPitch =\n clamp(orientation.pitch - step, -90, 90) - orientation.pitch;\n }\n onOrbit?.({ deltaYaw: 0, deltaPitch });\n handled = true;\n }\n break;\n case '1':\n if (allowSnap) {\n onSnapToView?.(e.ctrlKey ? 'back' : 'front');\n handled = true;\n }\n break;\n case '3':\n if (allowSnap) {\n onSnapToView?.(e.ctrlKey ? 'left' : 'right');\n handled = true;\n }\n break;\n case '7':\n if (allowSnap) {\n onSnapToView?.(e.ctrlKey ? 'bottom' : 'top');\n handled = true;\n }\n break;\n case '5':\n case 'Home':\n onOriginClick?.();\n handled = true;\n break;\n }\n\n if (handled) {\n e.preventDefault();\n }\n },\n [\n disabled,\n interactionMode,\n allowOrbit,\n allowSnap,\n constrainPitch,\n orientation,\n onOrbit,\n onSnapToView,\n onOriginClick,\n ]\n );\n\n return {\n isDragging,\n hoveredRegion,\n handlers: {\n onPointerDown,\n onPointerMove,\n onPointerUp,\n onKeyDown,\n },\n };\n}\n"],"names":[],"mappings":";;;;;AA4CA;AAEA;AACA;AAEM;AAGJ;;;;AAmBA;;AAEA;AACA;AACA;;;;;AAaA;AAEI;AACA;;AACA;;AAEF;AAIF;;;AAII;AACA;;;AAIA;AACA;AACA;AACA;;AAEA;;AAGA;;AAEF;;;;;;;;;AAUC;AAGH;AAEI;AACA;;AAEA;;;;;;AAOE;AACE;;AAGF;AACE;;;AAIE;AACA;;;;AAMJ;;;;;;;AAOF;;;;AAKE;;AACK;AACL;;;AAEA;;;AAEA;;AAEJ;;;;;;;;;;;;;AAcC;AAGH;AAEI;AACA;;AAEA;AACE;;AAEA;AAEA;;AAEE;;;;;AAIA;AASA;;;;;AAIE;AACA;AAAU;;;;AAGV;AACA;AAAU;;;;AAIlB;;;;;;;;;;;;AAaC;AAGH;AAEI;;AAEA;;;AAIA;AACE;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;;;;;AAKQ;;;;;;AAMR;;AAEI;;;AAGI;;;;;;AAMR;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;AACA;;;;;;;;AASJ;;;;;;;;;;AAWC;;;;AAMD;;;;;AAKC;;AAEL;;"}
1
+ {"version":3,"file":"useGizmoInteraction.js","sources":["src/components/editor/ViewportGizmo/useGizmoInteraction.ts"],"sourcesContent":["'use client';\n\nimport { useState, useCallback, useMemo, useRef } from 'react';\n\nimport { clamp } from '@/utils/mathUtils';\n\nimport { gizmoHitTest, axisToPresetView } from './gizmoMath';\n\nimport type {\n GizmoOrientation,\n GizmoUpAxis,\n GizmoPresetView,\n GizmoHitRegion,\n OrbitDelta,\n} from './ViewportGizmo.types';\nimport type React from 'react';\n\ninterface UseGizmoInteractionOptions {\n orientation: GizmoOrientation;\n canvasRef: React.RefObject<HTMLCanvasElement | null>;\n diameter: number;\n upAxis: GizmoUpAxis;\n interactionMode: 'full' | 'snap-only' | 'orbit-only' | 'display-only';\n orbitSpeed: number;\n constrainPitch: boolean;\n invertYaw: boolean;\n invertPitch: boolean;\n disabled: boolean;\n onOrbit?: (delta: OrbitDelta) => void;\n onOrbitEnd?: (finalOrientation: GizmoOrientation) => void;\n onSnapToView?: (view: GizmoPresetView) => void;\n onAxisClick?: (axis: 'x' | 'y' | 'z', positive: boolean) => void;\n onOriginClick?: () => void;\n}\n\ninterface UseGizmoInteractionReturn {\n isDragging: boolean;\n hoveredRegion: GizmoHitRegion;\n handlers: {\n onPointerDown: (e: React.PointerEvent) => void;\n onPointerMove: (e: React.PointerEvent) => void;\n onPointerUp: (e: React.PointerEvent) => void;\n onKeyDown: (e: React.KeyboardEvent) => void;\n };\n}\n\nconst NONE_HIT: GizmoHitRegion = { type: 'none', distance: Infinity };\n\nconst ORBIT_KEY_STEP = 15;\nconst ORBIT_FINE_STEP = 5;\n\nexport function useGizmoInteraction(\n options: UseGizmoInteractionOptions\n): UseGizmoInteractionReturn {\n const {\n orientation,\n canvasRef,\n diameter,\n upAxis,\n interactionMode,\n orbitSpeed,\n constrainPitch,\n invertYaw,\n invertPitch,\n disabled,\n onOrbit,\n onOrbitEnd,\n onSnapToView,\n onAxisClick,\n onOriginClick,\n } = options;\n\n const [hoveredRegion, setHoveredRegion] = useState<GizmoHitRegion>(NONE_HIT);\n // Ref drives synchronous reads inside the pointer handlers; the state mirror\n // is the value exposed for rendering (kept in sync on drag start/end).\n const isDraggingRef = useRef(false);\n const [isDragging, setIsDragging] = useState(false);\n const lastPointerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });\n const startPointerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });\n const hasDraggedRef = useRef(false);\n\n const allowSnap =\n interactionMode === 'full' || interactionMode === 'snap-only';\n const allowOrbit =\n interactionMode === 'full' || interactionMode === 'orbit-only';\n\n const armLength = (diameter / 2) * 0.65;\n const center = useMemo(\n () => ({ x: diameter / 2, y: diameter / 2 }),\n [diameter]\n );\n\n const getPointerPos = useCallback(\n (e: React.PointerEvent) => {\n const canvas = canvasRef.current;\n if (!canvas) return { px: 0, py: 0 };\n const rect = canvas.getBoundingClientRect();\n return { px: e.clientX - rect.left, py: e.clientY - rect.top };\n },\n [canvasRef]\n );\n\n // Single emission point for orbit deltas so the pointer and keyboard paths\n // stay consistent. Inversion is applied last, then pitch is constrained\n // against the value that will actually be applied — this keeps the\n // constraint correct at both poles regardless of `invertPitch`.\n const emitOrbit = useCallback(\n (rawYaw: number, rawPitch: number) => {\n const deltaYaw = invertYaw ? -rawYaw : rawYaw;\n let deltaPitch = invertPitch ? -rawPitch : rawPitch;\n\n if (constrainPitch) {\n deltaPitch =\n clamp(orientation.pitch + deltaPitch, -90, 90) - orientation.pitch;\n }\n\n onOrbit?.({ deltaYaw, deltaPitch });\n },\n [invertYaw, invertPitch, constrainPitch, orientation.pitch, onOrbit]\n );\n\n const onPointerDown = useCallback(\n (e: React.PointerEvent) => {\n if (disabled || interactionMode === 'display-only' || e.button !== 0)\n return;\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const { px, py } = getPointerPos(e);\n\n isDraggingRef.current = true;\n hasDraggedRef.current = false;\n lastPointerRef.current = { x: e.clientX, y: e.clientY };\n startPointerRef.current = { x: e.clientX, y: e.clientY };\n setIsDragging(true);\n canvas.setPointerCapture(e.pointerId);\n\n // Store hit for click detection on pointer up\n const hit = gizmoHitTest(px, py, orientation, center, armLength, upAxis);\n setHoveredRegion(hit);\n },\n [\n disabled,\n interactionMode,\n canvasRef,\n getPointerPos,\n orientation,\n center,\n armLength,\n upAxis,\n ]\n );\n\n const onPointerMove = useCallback(\n (e: React.PointerEvent) => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n if (isDraggingRef.current && allowOrbit) {\n const dx = e.clientX - lastPointerRef.current.x;\n const dy = e.clientY - lastPointerRef.current.y;\n\n // Only count as drag if moved > 3px from start\n const totalDx = e.clientX - startPointerRef.current.x;\n const totalDy = e.clientY - startPointerRef.current.y;\n if (Math.sqrt(totalDx ** 2 + totalDy ** 2) > 3) {\n hasDraggedRef.current = true;\n }\n\n if (hasDraggedRef.current) {\n emitOrbit(dx * orbitSpeed * 0.5, -dy * orbitSpeed * 0.5);\n }\n\n lastPointerRef.current = { x: e.clientX, y: e.clientY };\n return;\n }\n\n // Hover detection\n if (interactionMode === 'display-only') return;\n const { px, py } = getPointerPos(e);\n const hit = gizmoHitTest(px, py, orientation, center, armLength, upAxis);\n setHoveredRegion(hit);\n\n // Cursor\n if (disabled) {\n canvas.style.cursor = 'default';\n } else if (hit.type === 'axis-positive' || hit.type === 'origin') {\n canvas.style.cursor = 'pointer';\n } else if (allowOrbit) {\n canvas.style.cursor = isDraggingRef.current ? 'grabbing' : 'grab';\n } else {\n canvas.style.cursor = 'default';\n }\n },\n [\n canvasRef,\n allowOrbit,\n orbitSpeed,\n emitOrbit,\n orientation,\n interactionMode,\n disabled,\n getPointerPos,\n center,\n armLength,\n upAxis,\n ]\n );\n\n const onPointerUp = useCallback(\n (e: React.PointerEvent) => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n if (isDraggingRef.current) {\n isDraggingRef.current = false;\n setIsDragging(false);\n canvas.releasePointerCapture(e.pointerId);\n\n if (hasDraggedRef.current) {\n // Finished orbit drag\n onOrbitEnd?.(orientation);\n } else if (allowSnap) {\n // Click (no drag) — check hit\n const { px, py } = getPointerPos(e);\n const hit = gizmoHitTest(\n px,\n py,\n orientation,\n center,\n armLength,\n upAxis\n );\n\n if (hit.type === 'origin') {\n onOriginClick?.();\n } else if (hit.type === 'axis-positive' && hit.axis) {\n onAxisClick?.(hit.axis, true);\n const view = axisToPresetView(hit.axis, true, upAxis);\n if (view) onSnapToView?.(view);\n } else if (hit.type === 'axis-negative' && hit.axis) {\n onAxisClick?.(hit.axis, false);\n const view = axisToPresetView(hit.axis, false, upAxis);\n if (view) onSnapToView?.(view);\n }\n }\n }\n },\n [\n canvasRef,\n allowSnap,\n getPointerPos,\n orientation,\n center,\n armLength,\n upAxis,\n onOrbitEnd,\n onOriginClick,\n onAxisClick,\n onSnapToView,\n ]\n );\n\n const onKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (disabled || interactionMode === 'display-only') return;\n\n const fine = e.shiftKey;\n const step = fine ? ORBIT_FINE_STEP : ORBIT_KEY_STEP;\n let handled = false;\n\n switch (e.key) {\n case 'ArrowLeft':\n if (allowOrbit) {\n emitOrbit(-step, 0);\n handled = true;\n }\n break;\n case 'ArrowRight':\n if (allowOrbit) {\n emitOrbit(step, 0);\n handled = true;\n }\n break;\n case 'ArrowUp':\n if (allowOrbit) {\n emitOrbit(0, step);\n handled = true;\n }\n break;\n case 'ArrowDown':\n if (allowOrbit) {\n emitOrbit(0, -step);\n handled = true;\n }\n break;\n case '1':\n if (allowSnap) {\n onSnapToView?.(e.ctrlKey ? 'back' : 'front');\n handled = true;\n }\n break;\n case '3':\n if (allowSnap) {\n onSnapToView?.(e.ctrlKey ? 'left' : 'right');\n handled = true;\n }\n break;\n case '7':\n if (allowSnap) {\n onSnapToView?.(e.ctrlKey ? 'bottom' : 'top');\n handled = true;\n }\n break;\n case '5':\n case 'Home':\n onOriginClick?.();\n handled = true;\n break;\n }\n\n if (handled) {\n e.preventDefault();\n }\n },\n [\n disabled,\n interactionMode,\n allowOrbit,\n allowSnap,\n emitOrbit,\n onSnapToView,\n onOriginClick,\n ]\n );\n\n return {\n isDragging,\n hoveredRegion,\n handlers: {\n onPointerDown,\n onPointerMove,\n onPointerUp,\n onKeyDown,\n },\n };\n}\n"],"names":[],"mappings":";;;;;AA8CA;AAEA;AACA;AAEM;AAGJ;;;;AAqBA;;AAEA;AACA;AACA;;;;;AAaA;AAEI;AACA;;AACA;;AAEF;;;;;;AAUE;AACA;;;AAII;;;AAIN;AAIF;;;AAII;AACA;;;AAIA;AACA;AACA;AACA;;AAEA;;AAGA;;AAEF;;;;;;;;;AAUC;AAGH;AAEI;AACA;;AAEA;;;;;;AAOE;AACE;;AAGF;AACE;;AAGF;;;;;;;AAOF;;;;AAKE;;AACK;AACL;;;AAEA;;;AAEA;;AAEJ;;;;;;;;;;;;AAaC;AAGH;AAEI;AACA;;AAEA;AACE;;AAEA;AAEA;;AAEE;;;;;AAIA;AASA;;;;;AAIE;AACA;AAAU;;;;AAGV;AACA;AAAU;;;;AAIlB;;;;;;;;;;;;AAaC;AAGH;AAEI;;AAEA;;;AAIA;AACE;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;;AAEI;;;;AAIJ;AACA;;;;;;;;AASJ;;;;;;;;AASC;;;;AAMD;;;;;AAKC;;AAEL;;"}
@@ -9,6 +9,7 @@ import '../../primitives/Checkbox/Checkbox.js';
9
9
  import '../../primitives/Checkbox/CheckboxGroup.js';
10
10
  import '../../primitives/Code/Code.js';
11
11
  import '../../primitives/Collapsible/Collapsible.js';
12
+ import '../../primitives/EditableText/EditableText.js';
12
13
  import '../../primitives/HoverCard/HoverCard.js';
13
14
  import '../../primitives/Icon/Icon.js';
14
15
  import '../../primitives/IconButton/IconButton.js';
@@ -1 +1 @@
1
- {"version":3,"file":"BreadcrumbEllipsis.js","sources":["src/components/navigation/Breadcrumbs/BreadcrumbEllipsis.tsx"],"sourcesContent":["'use client';\n\nimport React from 'react';\n\nimport { Tooltip } from '@/components/primitives';\nimport { cx } from '@/utils/cx';\n\nimport {\n breadcrumbEllipsisButtonStyle,\n breadcrumbEllipsisStyle,\n breadcrumbEllipsisTextStyle,\n} from './Breadcrumbs.css';\n\nimport type { BreadcrumbEllipsisProps } from './Breadcrumbs.types';\n\n/**\n * Ellipsis placeholder for collapsed breadcrumb segments.\n *\n * @example\n * ```tsx\n * <BreadcrumbEllipsis onClick={expand} tooltip=\"src / components\" />\n * ```\n */\nexport const BreadcrumbEllipsis =\n /*#__PURE__*/ React.memo<BreadcrumbEllipsisProps>(\n ({ onClick, tooltip, className, style, testId, ref, ...rest }) => {\n const ellipsis = onClick ? (\n <button\n type=\"button\"\n className={breadcrumbEllipsisButtonStyle}\n onClick={onClick}\n aria-label=\"Show all breadcrumbs\"\n >\n …\n </button>\n ) : (\n <span className={breadcrumbEllipsisTextStyle}>…</span>\n );\n\n return (\n <li\n ref={ref as React.Ref<HTMLLIElement>}\n className={cx(breadcrumbEllipsisStyle, className)}\n style={style}\n data-testid={testId}\n {...rest}\n >\n {tooltip ? (\n <Tooltip title={tooltip} delay={300}>\n {ellipsis}\n </Tooltip>\n ) : (\n ellipsis\n )}\n </li>\n );\n }\n );\n\nBreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeA;;;;;;;AAOG;;AAED;AAEI;;AA8BF;AAGJ;;"}
1
+ {"version":3,"file":"BreadcrumbEllipsis.js","sources":["src/components/navigation/Breadcrumbs/BreadcrumbEllipsis.tsx"],"sourcesContent":["'use client';\n\nimport React from 'react';\n\nimport { Tooltip } from '@/components/primitives';\nimport { cx } from '@/utils/cx';\n\nimport {\n breadcrumbEllipsisButtonStyle,\n breadcrumbEllipsisStyle,\n breadcrumbEllipsisTextStyle,\n} from './Breadcrumbs.css';\n\nimport type { BreadcrumbEllipsisProps } from './Breadcrumbs.types';\n\n/**\n * Ellipsis placeholder for collapsed breadcrumb segments.\n *\n * @example\n * ```tsx\n * <BreadcrumbEllipsis onClick={expand} tooltip=\"src / components\" />\n * ```\n */\nexport const BreadcrumbEllipsis =\n /*#__PURE__*/ React.memo<BreadcrumbEllipsisProps>(\n ({ onClick, tooltip, className, style, testId, ref, ...rest }) => {\n const ellipsis = onClick ? (\n <button\n type=\"button\"\n className={breadcrumbEllipsisButtonStyle}\n onClick={onClick}\n aria-label=\"Show all breadcrumbs\"\n >\n …\n </button>\n ) : (\n <span className={breadcrumbEllipsisTextStyle}>…</span>\n );\n\n return (\n <li\n ref={ref as React.Ref<HTMLLIElement>}\n className={cx(breadcrumbEllipsisStyle, className)}\n style={style}\n data-testid={testId}\n {...rest}\n >\n {tooltip ? (\n <Tooltip title={tooltip} delay={300}>\n {ellipsis}\n </Tooltip>\n ) : (\n ellipsis\n )}\n </li>\n );\n }\n );\n\nBreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeA;;;;;;;AAOG;;AAED;AAEI;;AA8BF;AAGJ;;"}
@@ -9,6 +9,7 @@ import '../../primitives/Checkbox/Checkbox.js';
9
9
  import '../../primitives/Checkbox/CheckboxGroup.js';
10
10
  import '../../primitives/Code/Code.js';
11
11
  import '../../primitives/Collapsible/Collapsible.js';
12
+ import '../../primitives/EditableText/EditableText.js';
12
13
  import '../../primitives/HoverCard/HoverCard.js';
13
14
  import '../../primitives/Icon/Icon.js';
14
15
  import '../../primitives/IconButton/IconButton.js';
@@ -1 +1 @@
1
- {"version":3,"file":"BreadcrumbItem.js","sources":["src/components/navigation/Breadcrumbs/BreadcrumbItem.tsx"],"sourcesContent":["'use client';\n\nimport React, { useCallback } from 'react';\n\nimport { Tooltip } from '@/components/primitives';\nimport { cx } from '@/utils/cx';\n\nimport {\n breadcrumbContentRecipe,\n breadcrumbIconStyle,\n breadcrumbItemStyle,\n breadcrumbLabelStyle,\n} from './Breadcrumbs.css';\n\nimport type { BreadcrumbItemProps } from './Breadcrumbs.types';\n\nfunction getTruncatedLabel(children: React.ReactNode, maxLength?: number) {\n if (\n typeof children !== 'string' ||\n !maxLength ||\n children.length <= maxLength\n ) {\n return { label: children, fullLabel: undefined };\n }\n\n const endIndex = Math.max(0, maxLength - 1);\n return {\n label: `${children.slice(0, endIndex)}…`,\n fullLabel: children,\n };\n}\n\n/**\n * Individual segment in a breadcrumb trail.\n *\n * Renders as a link, clickable span for client routing, current-page text, or\n * disabled text depending on the props provided.\n *\n * @example\n * ```tsx\n * <BreadcrumbItem href=\"/components\">Components</BreadcrumbItem>\n * <BreadcrumbItem isCurrent>Button</BreadcrumbItem>\n * ```\n */\nexport const BreadcrumbItem = /*#__PURE__*/ React.memo<BreadcrumbItemProps>(\n ({\n href,\n onClick,\n isCurrent = false,\n icon,\n maxLength,\n endContent,\n children,\n className,\n style,\n testId,\n ref,\n ...rest\n }) => {\n const { label, fullLabel } = getTruncatedLabel(children, maxLength);\n const isInteractive =\n (href !== undefined || onClick !== undefined) && !isCurrent;\n const state = isCurrent ? 'current' : isInteractive ? 'link' : 'disabled';\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLSpanElement>) => {\n if (!onClick || isCurrent) {\n return;\n }\n\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n event.currentTarget.click();\n }\n },\n [isCurrent, onClick]\n );\n\n const content = (\n <>\n {icon && <span className={breadcrumbIconStyle}>{icon}</span>}\n <span className={breadcrumbLabelStyle}>{label}</span>\n </>\n );\n\n let inner: React.ReactElement;\n\n if (isCurrent) {\n inner = (\n <span\n className={breadcrumbContentRecipe({ state })}\n aria-current=\"page\"\n >\n {content}\n </span>\n );\n } else if (href) {\n inner = (\n <a className={breadcrumbContentRecipe({ state })} href={href}>\n {content}\n </a>\n );\n } else if (onClick) {\n inner = (\n <span\n className={breadcrumbContentRecipe({ state })}\n role=\"button\"\n tabIndex={0}\n onClick={onClick}\n onKeyDown={handleKeyDown}\n >\n {content}\n </span>\n );\n } else {\n inner = (\n <span className={breadcrumbContentRecipe({ state })}>{content}</span>\n );\n }\n\n return (\n <li\n ref={ref as React.Ref<HTMLLIElement>}\n className={cx(breadcrumbItemStyle, className)}\n style={style}\n data-testid={testId}\n {...rest}\n >\n {fullLabel ? (\n <Tooltip title={fullLabel} delay={300}>\n {inner}\n </Tooltip>\n ) : (\n inner\n )}\n {endContent}\n </li>\n );\n }\n);\n\nBreadcrumbItem.displayName = 'BreadcrumbItem';\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBA;;AAGI;AACA;;;AAKF;;;AAGE;;AAEJ;AAEA;;;;;;;;;;;AAWG;AACI;AAeH;AACA;AAEA;AAEA;AAEI;;;AAIA;;AAEE;;AAEJ;;AAWF;;AAGE;;;AASA;;;AAMA;;;AAYA;;;AAuBJ;AAGF;;"}
1
+ {"version":3,"file":"BreadcrumbItem.js","sources":["src/components/navigation/Breadcrumbs/BreadcrumbItem.tsx"],"sourcesContent":["'use client';\n\nimport React, { useCallback } from 'react';\n\nimport { Tooltip } from '@/components/primitives';\nimport { cx } from '@/utils/cx';\n\nimport {\n breadcrumbContentRecipe,\n breadcrumbIconStyle,\n breadcrumbItemStyle,\n breadcrumbLabelStyle,\n} from './Breadcrumbs.css';\n\nimport type { BreadcrumbItemProps } from './Breadcrumbs.types';\n\nfunction getTruncatedLabel(children: React.ReactNode, maxLength?: number) {\n if (\n typeof children !== 'string' ||\n !maxLength ||\n children.length <= maxLength\n ) {\n return { label: children, fullLabel: undefined };\n }\n\n const endIndex = Math.max(0, maxLength - 1);\n return {\n label: `${children.slice(0, endIndex)}…`,\n fullLabel: children,\n };\n}\n\n/**\n * Individual segment in a breadcrumb trail.\n *\n * Renders as a link, clickable span for client routing, current-page text, or\n * disabled text depending on the props provided.\n *\n * @example\n * ```tsx\n * <BreadcrumbItem href=\"/components\">Components</BreadcrumbItem>\n * <BreadcrumbItem isCurrent>Button</BreadcrumbItem>\n * ```\n */\nexport const BreadcrumbItem = /*#__PURE__*/ React.memo<BreadcrumbItemProps>(\n ({\n href,\n onClick,\n isCurrent = false,\n icon,\n maxLength,\n endContent,\n children,\n className,\n style,\n testId,\n ref,\n ...rest\n }) => {\n const { label, fullLabel } = getTruncatedLabel(children, maxLength);\n const isInteractive =\n (href !== undefined || onClick !== undefined) && !isCurrent;\n const state = isCurrent ? 'current' : isInteractive ? 'link' : 'disabled';\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLSpanElement>) => {\n if (!onClick || isCurrent) {\n return;\n }\n\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n event.currentTarget.click();\n }\n },\n [isCurrent, onClick]\n );\n\n const content = (\n <>\n {icon && <span className={breadcrumbIconStyle}>{icon}</span>}\n <span className={breadcrumbLabelStyle}>{label}</span>\n </>\n );\n\n let inner: React.ReactElement;\n\n if (isCurrent) {\n inner = (\n <span\n className={breadcrumbContentRecipe({ state })}\n aria-current=\"page\"\n >\n {content}\n </span>\n );\n } else if (href) {\n inner = (\n <a className={breadcrumbContentRecipe({ state })} href={href}>\n {content}\n </a>\n );\n } else if (onClick) {\n inner = (\n <span\n className={breadcrumbContentRecipe({ state })}\n role=\"button\"\n tabIndex={0}\n onClick={onClick}\n onKeyDown={handleKeyDown}\n >\n {content}\n </span>\n );\n } else {\n inner = (\n <span className={breadcrumbContentRecipe({ state })}>{content}</span>\n );\n }\n\n return (\n <li\n ref={ref as React.Ref<HTMLLIElement>}\n className={cx(breadcrumbItemStyle, className)}\n style={style}\n data-testid={testId}\n {...rest}\n >\n {fullLabel ? (\n <Tooltip title={fullLabel} delay={300}>\n {inner}\n </Tooltip>\n ) : (\n inner\n )}\n {endContent}\n </li>\n );\n }\n);\n\nBreadcrumbItem.displayName = 'BreadcrumbItem';\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBA;;AAGI;AACA;;;AAKF;;;AAGE;;AAEJ;AAEA;;;;;;;;;;;AAWG;AACI;AAeH;AACA;AAEA;AAEA;AAEI;;;AAIA;;AAEE;;AAEJ;;AAWF;;AAGE;;;AASA;;;AAMA;;;AAYA;;;AAuBJ;AAGF;;"}
@@ -102,6 +102,7 @@ import '../../primitives/Checkbox/Checkbox.js';
102
102
  import '../../primitives/Checkbox/CheckboxGroup.js';
103
103
  import '../../primitives/Code/Code.js';
104
104
  import '../../primitives/Collapsible/Collapsible.js';
105
+ import '../../primitives/EditableText/EditableText.js';
105
106
  import '../../primitives/HoverCard/HoverCard.js';
106
107
  import '../../primitives/Icon/Icon.js';
107
108
  import { IconButton } from '../../primitives/IconButton/IconButton.js';
@@ -1 +1 @@
1
- {"version":3,"file":"PathBarSiblingMenu.js","sources":["src/components/navigation/PathBar/PathBarSiblingMenu.tsx"],"sourcesContent":["'use client';\n\nimport React from 'react';\n\nimport { ChevronDownIcon } from '@/components/Icons';\nimport { Menu } from '@/components/navigation/Menu';\nimport { IconButton } from '@/components/primitives';\n\nimport { pathBarSiblingTriggerStyle } from './PathBar.css';\nimport { resolveSiblings } from './pathUtils';\n\nimport type { PathBarSize, PathSegment } from './PathBar.types';\nimport type { ResolvedPathSegment } from './pathUtils';\n\ninterface PathBarSiblingMenuProps {\n /** The resolved segment this dropdown hangs off — the sibling anchor. */\n segment: ResolvedPathSegment;\n /** Index of `segment` in the trail; forwarded to `onSelect`. */\n index: number;\n /** Returns the raw sibling entries for the segment. */\n getSiblings: (\n segment: PathSegment,\n index: number\n ) => ReadonlyArray<string | PathSegment>;\n /** Called with the chosen sibling (already resolved to a concrete value). */\n onSelect: (index: number, sibling: ResolvedPathSegment) => void;\n /** Scale shared with the rest of the bar — sizes the caret button and icon. */\n size: PathBarSize;\n}\n\n/**\n * Trailing caret + dropdown for a single crumb's siblings (the VS Code path-bar\n * affordance). Rendered as a crumb's `endContent`, so it mounts only for crumbs\n * Breadcrumbs actually renders — `getSiblings` runs lazily, never for segments\n * hidden behind the overflow ellipsis. Renders nothing when the segment has no\n * siblings.\n */\nexport const PathBarSiblingMenu = ({\n segment,\n index,\n getSiblings,\n onSelect,\n size,\n}: PathBarSiblingMenuProps): React.ReactElement | null => {\n const siblings = resolveSiblings(segment, getSiblings(segment, index));\n if (siblings.length === 0) {\n return null;\n }\n\n return (\n <Menu>\n <Menu.Trigger\n render={\n <IconButton\n aria-label={`Browse ${segment.label} siblings`}\n size={size}\n radius=\"sm\"\n className={pathBarSiblingTriggerStyle}\n >\n <ChevronDownIcon size={size} decorative />\n </IconButton>\n }\n />\n <Menu.Content>\n {siblings.map((sibling, siblingIndex) => (\n <Menu.Item\n key={`${sibling.value}-${siblingIndex}`}\n icon={sibling.icon}\n onSelect={() => {\n onSelect(index, sibling);\n }}\n >\n {sibling.label}\n </Menu.Item>\n ))}\n </Menu.Content>\n </Menu>\n );\n};\n\nPathBarSiblingMenu.displayName = 'PathBarSiblingMenu';\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA;;;;;;AAMG;AACI;AAOL;AACA;AACE;;AAGF;AAoBY;AACF;AAQZ;AAEA;;"}
1
+ {"version":3,"file":"PathBarSiblingMenu.js","sources":["src/components/navigation/PathBar/PathBarSiblingMenu.tsx"],"sourcesContent":["'use client';\n\nimport React from 'react';\n\nimport { ChevronDownIcon } from '@/components/Icons';\nimport { Menu } from '@/components/navigation/Menu';\nimport { IconButton } from '@/components/primitives';\n\nimport { pathBarSiblingTriggerStyle } from './PathBar.css';\nimport { resolveSiblings } from './pathUtils';\n\nimport type { PathBarSize, PathSegment } from './PathBar.types';\nimport type { ResolvedPathSegment } from './pathUtils';\n\ninterface PathBarSiblingMenuProps {\n /** The resolved segment this dropdown hangs off — the sibling anchor. */\n segment: ResolvedPathSegment;\n /** Index of `segment` in the trail; forwarded to `onSelect`. */\n index: number;\n /** Returns the raw sibling entries for the segment. */\n getSiblings: (\n segment: PathSegment,\n index: number\n ) => ReadonlyArray<string | PathSegment>;\n /** Called with the chosen sibling (already resolved to a concrete value). */\n onSelect: (index: number, sibling: ResolvedPathSegment) => void;\n /** Scale shared with the rest of the bar — sizes the caret button and icon. */\n size: PathBarSize;\n}\n\n/**\n * Trailing caret + dropdown for a single crumb's siblings (the VS Code path-bar\n * affordance). Rendered as a crumb's `endContent`, so it mounts only for crumbs\n * Breadcrumbs actually renders — `getSiblings` runs lazily, never for segments\n * hidden behind the overflow ellipsis. Renders nothing when the segment has no\n * siblings.\n */\nexport const PathBarSiblingMenu = ({\n segment,\n index,\n getSiblings,\n onSelect,\n size,\n}: PathBarSiblingMenuProps): React.ReactElement | null => {\n const siblings = resolveSiblings(segment, getSiblings(segment, index));\n if (siblings.length === 0) {\n return null;\n }\n\n return (\n <Menu>\n <Menu.Trigger\n render={\n <IconButton\n aria-label={`Browse ${segment.label} siblings`}\n size={size}\n radius=\"sm\"\n className={pathBarSiblingTriggerStyle}\n >\n <ChevronDownIcon size={size} decorative />\n </IconButton>\n }\n />\n <Menu.Content>\n {siblings.map((sibling, siblingIndex) => (\n <Menu.Item\n key={`${sibling.value}-${siblingIndex}`}\n icon={sibling.icon}\n onSelect={() => {\n onSelect(index, sibling);\n }}\n >\n {sibling.label}\n </Menu.Item>\n ))}\n </Menu.Content>\n </Menu>\n );\n};\n\nPathBarSiblingMenu.displayName = 'PathBarSiblingMenu';\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA;;;;;;AAMG;AACI;AAOL;AACA;AACE;;AAGF;AAoBY;AACF;AAQZ;AAEA;;"}
@@ -0,0 +1,11 @@
1
+ import './../../../assets/src/components/primitives/EditableText/EditableText.css.ts.vanilla-CrVGgD19.css';
2
+
3
+ var displayDisabledStyle = 'EditableText_displayDisabledStyle__d2an012';
4
+ var displayReadOnlyStyle = 'EditableText_displayReadOnlyStyle__d2an011';
5
+ var displayStyle = 'EditableText_displayStyle__d2an010';
6
+ var editWrapStyle = 'EditableText_editWrapStyle__d2an013';
7
+ var inputStyle = 'EditableText_inputStyle__d2an016 EditableText_sharedCell__d2an014';
8
+ var sizerStyle = 'EditableText_sizerStyle__d2an015 EditableText_sharedCell__d2an014';
9
+
10
+ export { displayDisabledStyle, displayReadOnlyStyle, displayStyle, editWrapStyle, inputStyle, sizerStyle };
11
+ //# sourceMappingURL=EditableText.css.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EditableText.css.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;"}
@@ -0,0 +1,208 @@
1
+ "use client";
2
+ import { jsxs, jsx } from 'react/jsx-runtime';
3
+ import { useState, useRef, useMemo, useCallback, useImperativeHandle } from 'react';
4
+ import { Text } from '../Text/Text.js';
5
+ import { textRecipe } from '../Text/Text.css.js';
6
+ import '../../../utils/devWarn.js';
7
+ import { useControlledState } from '../../../hooks/useControlledState/useControlledState.js';
8
+ import { useMergedRef } from '../../../hooks/useMergedRef/useMergedRef.js';
9
+ import { useLatest } from '../../../hooks/useLatest/useLatest.js';
10
+ import '../../../hooks/useBreakpoint/useBreakpoint.js';
11
+ import './../../../assets/src/theme/lightTheme.css.ts.vanilla-Ct6f9Iym.css';
12
+ import '@vanilla-extract/css';
13
+ import './../../../assets/src/theme/darkTheme.css.ts.vanilla-BHZ3ecRn.css';
14
+ import './../../../assets/src/theme/globalScrollbars.css.ts.vanilla-BAJwnUEJ.css';
15
+ import '../../../hooks/useNavigationHistory/useNavigationHistory.js';
16
+ import { cx } from '../../../utils/cx.js';
17
+ import { sizerStyle, inputStyle, editWrapStyle, displayDisabledStyle, displayReadOnlyStyle, displayStyle } from './EditableText.css.js';
18
+ import { resolveEditableTextLabels } from './editableTextLabels.js';
19
+
20
+ /**
21
+ * Text that looks like `<Text>` but turns into an inline editor when the user
22
+ * activates it — the editor-UI pattern for renaming layers, nodes, assets, and
23
+ * scene objects in place.
24
+ *
25
+ * The idle state renders a real `<Text>` (so it inherits every typography prop),
26
+ * and the editing state renders a chrome-less `<input>` that shares the exact
27
+ * same typography recipe, so the swap is visually seamless. The edit field
28
+ * auto-sizes to its content via a hidden sizer — no measurement effects.
29
+ *
30
+ * Editing commits on `Enter` (and on blur when `submitOnBlur`), cancels on
31
+ * `Escape`. `onChange` fires on commit only, with the new value.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * // Uncontrolled — click the text to rename in place
36
+ * <EditableText defaultValue="Untitled Layer" onChange={setName} />
37
+ *
38
+ * // Controlled, heading-styled, double-click to edit (rename convention)
39
+ * <EditableText
40
+ * variant="heading"
41
+ * value={name}
42
+ * onChange={setName}
43
+ * activationMode="double"
44
+ * placeholder="Name this node"
45
+ * />
46
+ * ```
47
+ */
48
+ const EditableText = ({ value, defaultValue, onChange, placeholder, as = 'span', variant = 'body', size, weight, color = 'primary', lineHeight, align, truncate = false, mono = false, activationMode = 'single', disabled = false, readOnly = false, selectOnEdit = true, submitOnBlur = true, maxLength, labels: labelsProp, 'aria-label': ariaLabel, onEditStart, onEditEnd, onKeyDown, className, style, testId, ref, }) => {
49
+ const [currentValue, setValue] = useControlledState({
50
+ value,
51
+ defaultValue,
52
+ onChange,
53
+ fallback: '',
54
+ });
55
+ const [isEditing, setIsEditing] = useState(false);
56
+ const [draft, setDraft] = useState('');
57
+ const displayRef = useRef(null);
58
+ const inputRef = useRef(null);
59
+ // When an edit ends via keyboard we return focus to the display; a blur-driven
60
+ // end must NOT steal focus back (the user just clicked somewhere else).
61
+ const refocusDisplayRef = useRef(false);
62
+ // Guards the input's blur handler from firing a second end after Enter/Escape
63
+ // already tore the input down.
64
+ const endingRef = useRef(false);
65
+ const currentValueRef = useLatest(currentValue);
66
+ const draftRef = useLatest(draft);
67
+ const submitOnBlurRef = useLatest(submitOnBlur);
68
+ const selectOnEditRef = useLatest(selectOnEdit);
69
+ const onEditStartRef = useLatest(onEditStart);
70
+ const onEditEndRef = useLatest(onEditEnd);
71
+ const onKeyDownRef = useLatest(onKeyDown);
72
+ const labels = useMemo(() => resolveEditableTextLabels(labelsProp), [labelsProp]);
73
+ const startEditing = useCallback(() => {
74
+ if (disabled || readOnly)
75
+ return;
76
+ endingRef.current = false;
77
+ setDraft(currentValueRef.current);
78
+ setIsEditing(true);
79
+ onEditStartRef.current?.();
80
+ }, [disabled, readOnly, currentValueRef, onEditStartRef]);
81
+ const endEditing = useCallback((reason, refocus) => {
82
+ endingRef.current = true;
83
+ refocusDisplayRef.current = refocus;
84
+ setIsEditing(false);
85
+ onEditEndRef.current?.(reason);
86
+ }, [onEditEndRef]);
87
+ const commit = useCallback((refocus) => {
88
+ if (endingRef.current)
89
+ return;
90
+ const next = draftRef.current;
91
+ if (next !== currentValueRef.current) {
92
+ setValue(next);
93
+ }
94
+ endEditing('commit', refocus);
95
+ }, [draftRef, currentValueRef, setValue, endEditing]);
96
+ const cancel = useCallback((refocus) => {
97
+ if (endingRef.current)
98
+ return;
99
+ endEditing('cancel', refocus);
100
+ }, [endEditing]);
101
+ useImperativeHandle(ref, () => ({
102
+ edit: startEditing,
103
+ commit: () => {
104
+ commit(false);
105
+ },
106
+ cancel: () => {
107
+ cancel(false);
108
+ },
109
+ focus: () => {
110
+ (inputRef.current ?? displayRef.current)?.focus();
111
+ },
112
+ isEditing: () => isEditing,
113
+ getElement: () => inputRef.current ?? displayRef.current,
114
+ }), [startEditing, commit, cancel, isEditing]);
115
+ // Focus (and optionally select) the input the moment it mounts, without an
116
+ // effect. Stable identity, so it fires once per edit session, not per render.
117
+ const focusInput = useCallback((node) => {
118
+ if (node) {
119
+ node.focus();
120
+ if (selectOnEditRef.current)
121
+ node.select();
122
+ }
123
+ }, [selectOnEditRef]);
124
+ const mergedInputRef = useMergedRef(inputRef, focusInput);
125
+ // Return focus to the display element after a keyboard-driven edit end.
126
+ const refocusDisplay = useCallback((node) => {
127
+ if (node && refocusDisplayRef.current) {
128
+ node.focus();
129
+ refocusDisplayRef.current = false;
130
+ }
131
+ }, []);
132
+ const mergedDisplayRef = useMergedRef(displayRef, refocusDisplay);
133
+ const handleDisplayKeyDown = useCallback((event) => {
134
+ if (event.key === 'Enter' || event.key === 'F2') {
135
+ event.preventDefault();
136
+ startEditing();
137
+ }
138
+ }, [startEditing]);
139
+ const handleInputKeyDown = useCallback((event) => {
140
+ if (event.key === 'Enter') {
141
+ event.preventDefault();
142
+ commit(true);
143
+ }
144
+ else if (event.key === 'Escape') {
145
+ event.preventDefault();
146
+ cancel(true);
147
+ }
148
+ onKeyDownRef.current?.(event);
149
+ }, [commit, cancel, onKeyDownRef]);
150
+ const handleInputBlur = useCallback(() => {
151
+ if (submitOnBlurRef.current) {
152
+ commit(false);
153
+ }
154
+ else {
155
+ cancel(false);
156
+ }
157
+ }, [commit, cancel, submitOnBlurRef]);
158
+ // Recipe args mirror Text's own gating so the input matches the display
159
+ // typography exactly.
160
+ const isInherit = variant === 'inherit';
161
+ const sizeVariant = size && !isInherit ? size : undefined;
162
+ const weightVariant = weight && !isInherit ? weight : undefined;
163
+ const lineHeightVariant = lineHeight && !isInherit ? lineHeight : undefined;
164
+ const useMono = mono || variant === 'code' ? true : undefined;
165
+ if (isEditing) {
166
+ const editTextClass = textRecipe({
167
+ variant,
168
+ color,
169
+ size: sizeVariant,
170
+ weight: weightVariant,
171
+ lineHeight: lineHeightVariant,
172
+ align,
173
+ mono: useMono,
174
+ });
175
+ return (
176
+ // The wrapper only groups the sizer + input; the input owns all
177
+ // interactivity, so the wrapper needs no role or key handler.
178
+ jsxs("span", { className: cx(editWrapStyle, className), style: style, "data-editing": "true", children: [jsx("span", { "aria-hidden": "true", className: cx(editTextClass, sizerStyle), children: `${draft.length > 0 ? draft : (placeholder ?? '')} ` }), jsx("input", { ref: mergedInputRef, type: "text", className: cx(editTextClass, inputStyle), value: draft, placeholder: placeholder, maxLength: maxLength, "aria-label": ariaLabel ?? labels.editLabel, onChange: event => {
179
+ setDraft(event.target.value);
180
+ }, onKeyDown: handleInputKeyDown, onBlur: handleInputBlur, onClick: event => {
181
+ // Don't let the click bubble to a parent row/select handler.
182
+ event.stopPropagation();
183
+ }, "data-testid": testId })] }));
184
+ }
185
+ const hasValue = currentValue.length > 0;
186
+ const showingPlaceholder = !hasValue && placeholder != null && placeholder.length > 0;
187
+ const displayContent = hasValue
188
+ ? currentValue
189
+ : showingPlaceholder
190
+ ? placeholder
191
+ : // Keep a clickable line box when there is nothing to show.
192
+ ' ';
193
+ const displayColor = disabled
194
+ ? 'disabled'
195
+ : showingPlaceholder
196
+ ? 'muted'
197
+ : color;
198
+ const interactive = !disabled && !readOnly;
199
+ // When the value is empty there is no meaningful text content to name the
200
+ // control, so fall back to the placeholder or the edit label.
201
+ const resolvedAriaLabel = ariaLabel ?? (hasValue ? undefined : (placeholder ?? labels.editLabel));
202
+ return (jsx(Text, { as: as, variant: variant, size: size, weight: weight, color: displayColor, lineHeight: lineHeight, align: align, truncate: truncate, mono: mono, ref: mergedDisplayRef, className: cx(displayStyle, !interactive &&
203
+ (disabled ? displayDisabledStyle : displayReadOnlyStyle), className), style: style, role: interactive ? 'button' : undefined, tabIndex: interactive ? 0 : undefined, "aria-label": resolvedAriaLabel, "aria-disabled": disabled || undefined, onClick: interactive && activationMode === 'single' ? startEditing : undefined, onDoubleClick: interactive && activationMode === 'double' ? startEditing : undefined, onKeyDown: interactive ? handleDisplayKeyDown : undefined, testId: testId, children: displayContent }));
204
+ };
205
+ EditableText.displayName = 'EditableText';
206
+
207
+ export { EditableText };
208
+ //# sourceMappingURL=EditableText.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EditableText.js","sources":["src/components/primitives/EditableText/EditableText.tsx"],"sourcesContent":["'use client';\n\nimport React, {\n useCallback,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react';\n\nimport { Text } from '@/components/primitives/Text';\nimport { textRecipe } from '@/components/primitives/Text/Text.css';\nimport { useControlledState, useLatest, useMergedRef } from '@/hooks';\nimport { cx } from '@/utils/cx';\n\nimport {\n displayStyle,\n displayReadOnlyStyle,\n displayDisabledStyle,\n editWrapStyle,\n sizerStyle,\n inputStyle,\n} from './EditableText.css';\nimport { resolveEditableTextLabels } from './editableTextLabels';\n\nimport type {\n EditableTextEndReason,\n EditableTextHandle,\n EditableTextProps,\n} from './EditableText.types';\n\n/**\n * Text that looks like `<Text>` but turns into an inline editor when the user\n * activates it — the editor-UI pattern for renaming layers, nodes, assets, and\n * scene objects in place.\n *\n * The idle state renders a real `<Text>` (so it inherits every typography prop),\n * and the editing state renders a chrome-less `<input>` that shares the exact\n * same typography recipe, so the swap is visually seamless. The edit field\n * auto-sizes to its content via a hidden sizer — no measurement effects.\n *\n * Editing commits on `Enter` (and on blur when `submitOnBlur`), cancels on\n * `Escape`. `onChange` fires on commit only, with the new value.\n *\n * @example\n * ```tsx\n * // Uncontrolled — click the text to rename in place\n * <EditableText defaultValue=\"Untitled Layer\" onChange={setName} />\n *\n * // Controlled, heading-styled, double-click to edit (rename convention)\n * <EditableText\n * variant=\"heading\"\n * value={name}\n * onChange={setName}\n * activationMode=\"double\"\n * placeholder=\"Name this node\"\n * />\n * ```\n */\nexport const EditableText = ({\n value,\n defaultValue,\n onChange,\n placeholder,\n as = 'span',\n variant = 'body',\n size,\n weight,\n color = 'primary',\n lineHeight,\n align,\n truncate = false,\n mono = false,\n activationMode = 'single',\n disabled = false,\n readOnly = false,\n selectOnEdit = true,\n submitOnBlur = true,\n maxLength,\n labels: labelsProp,\n 'aria-label': ariaLabel,\n onEditStart,\n onEditEnd,\n onKeyDown,\n className,\n style,\n testId,\n ref,\n}: EditableTextProps): React.ReactElement => {\n const [currentValue, setValue] = useControlledState({\n value,\n defaultValue,\n onChange,\n fallback: '',\n });\n\n const [isEditing, setIsEditing] = useState(false);\n const [draft, setDraft] = useState('');\n\n const displayRef = useRef<HTMLElement>(null);\n const inputRef = useRef<HTMLInputElement>(null);\n // When an edit ends via keyboard we return focus to the display; a blur-driven\n // end must NOT steal focus back (the user just clicked somewhere else).\n const refocusDisplayRef = useRef(false);\n // Guards the input's blur handler from firing a second end after Enter/Escape\n // already tore the input down.\n const endingRef = useRef(false);\n\n const currentValueRef = useLatest(currentValue);\n const draftRef = useLatest(draft);\n const submitOnBlurRef = useLatest(submitOnBlur);\n const selectOnEditRef = useLatest(selectOnEdit);\n const onEditStartRef = useLatest(onEditStart);\n const onEditEndRef = useLatest(onEditEnd);\n const onKeyDownRef = useLatest(onKeyDown);\n\n const labels = useMemo(\n () => resolveEditableTextLabels(labelsProp),\n [labelsProp]\n );\n\n const startEditing = useCallback(() => {\n if (disabled || readOnly) return;\n endingRef.current = false;\n setDraft(currentValueRef.current);\n setIsEditing(true);\n onEditStartRef.current?.();\n }, [disabled, readOnly, currentValueRef, onEditStartRef]);\n\n const endEditing = useCallback(\n (reason: EditableTextEndReason, refocus: boolean) => {\n endingRef.current = true;\n refocusDisplayRef.current = refocus;\n setIsEditing(false);\n onEditEndRef.current?.(reason);\n },\n [onEditEndRef]\n );\n\n const commit = useCallback(\n (refocus: boolean) => {\n if (endingRef.current) return;\n const next = draftRef.current;\n if (next !== currentValueRef.current) {\n setValue(next);\n }\n endEditing('commit', refocus);\n },\n [draftRef, currentValueRef, setValue, endEditing]\n );\n\n const cancel = useCallback(\n (refocus: boolean) => {\n if (endingRef.current) return;\n endEditing('cancel', refocus);\n },\n [endEditing]\n );\n\n useImperativeHandle(\n ref,\n (): EditableTextHandle => ({\n edit: startEditing,\n commit: () => {\n commit(false);\n },\n cancel: () => {\n cancel(false);\n },\n focus: () => {\n (inputRef.current ?? displayRef.current)?.focus();\n },\n isEditing: () => isEditing,\n getElement: () => inputRef.current ?? displayRef.current,\n }),\n [startEditing, commit, cancel, isEditing]\n );\n\n // Focus (and optionally select) the input the moment it mounts, without an\n // effect. Stable identity, so it fires once per edit session, not per render.\n const focusInput = useCallback(\n (node: HTMLInputElement | null) => {\n if (node) {\n node.focus();\n if (selectOnEditRef.current) node.select();\n }\n },\n [selectOnEditRef]\n );\n const mergedInputRef = useMergedRef(inputRef, focusInput);\n\n // Return focus to the display element after a keyboard-driven edit end.\n const refocusDisplay = useCallback((node: HTMLElement | null) => {\n if (node && refocusDisplayRef.current) {\n node.focus();\n refocusDisplayRef.current = false;\n }\n }, []);\n const mergedDisplayRef = useMergedRef(displayRef, refocusDisplay);\n\n const handleDisplayKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLElement>) => {\n if (event.key === 'Enter' || event.key === 'F2') {\n event.preventDefault();\n startEditing();\n }\n },\n [startEditing]\n );\n\n const handleInputKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'Enter') {\n event.preventDefault();\n commit(true);\n } else if (event.key === 'Escape') {\n event.preventDefault();\n cancel(true);\n }\n onKeyDownRef.current?.(event);\n },\n [commit, cancel, onKeyDownRef]\n );\n\n const handleInputBlur = useCallback(() => {\n if (submitOnBlurRef.current) {\n commit(false);\n } else {\n cancel(false);\n }\n }, [commit, cancel, submitOnBlurRef]);\n\n // Recipe args mirror Text's own gating so the input matches the display\n // typography exactly.\n const isInherit = variant === 'inherit';\n const sizeVariant = size && !isInherit ? size : undefined;\n const weightVariant = weight && !isInherit ? weight : undefined;\n const lineHeightVariant = lineHeight && !isInherit ? lineHeight : undefined;\n const useMono = mono || variant === 'code' ? true : undefined;\n\n if (isEditing) {\n const editTextClass = textRecipe({\n variant,\n color,\n size: sizeVariant,\n weight: weightVariant,\n lineHeight: lineHeightVariant,\n align,\n mono: useMono,\n });\n\n return (\n // The wrapper only groups the sizer + input; the input owns all\n // interactivity, so the wrapper needs no role or key handler.\n <span\n className={cx(editWrapStyle, className)}\n style={style}\n data-editing=\"true\"\n >\n <span aria-hidden=\"true\" className={cx(editTextClass, sizerStyle)}>\n {`${draft.length > 0 ? draft : (placeholder ?? '')} `}\n </span>\n <input\n ref={mergedInputRef}\n type=\"text\"\n className={cx(editTextClass, inputStyle)}\n value={draft}\n placeholder={placeholder}\n maxLength={maxLength}\n aria-label={ariaLabel ?? labels.editLabel}\n onChange={event => {\n setDraft(event.target.value);\n }}\n onKeyDown={handleInputKeyDown}\n onBlur={handleInputBlur}\n onClick={event => {\n // Don't let the click bubble to a parent row/select handler.\n event.stopPropagation();\n }}\n data-testid={testId}\n />\n </span>\n );\n }\n\n const hasValue = currentValue.length > 0;\n const showingPlaceholder =\n !hasValue && placeholder != null && placeholder.length > 0;\n const displayContent = hasValue\n ? currentValue\n : showingPlaceholder\n ? placeholder\n : // Keep a clickable line box when there is nothing to show.\n ' ';\n\n const displayColor = disabled\n ? 'disabled'\n : showingPlaceholder\n ? 'muted'\n : color;\n\n const interactive = !disabled && !readOnly;\n\n // When the value is empty there is no meaningful text content to name the\n // control, so fall back to the placeholder or the edit label.\n const resolvedAriaLabel =\n ariaLabel ?? (hasValue ? undefined : (placeholder ?? labels.editLabel));\n\n return (\n <Text\n as={as}\n variant={variant}\n size={size}\n weight={weight}\n color={displayColor}\n lineHeight={lineHeight}\n align={align}\n truncate={truncate}\n mono={mono}\n ref={mergedDisplayRef}\n className={cx(\n displayStyle,\n !interactive &&\n (disabled ? displayDisabledStyle : displayReadOnlyStyle),\n className\n )}\n style={style}\n role={interactive ? 'button' : undefined}\n tabIndex={interactive ? 0 : undefined}\n aria-label={resolvedAriaLabel}\n aria-disabled={disabled || undefined}\n onClick={\n interactive && activationMode === 'single' ? startEditing : undefined\n }\n onDoubleClick={\n interactive && activationMode === 'double' ? startEditing : undefined\n }\n onKeyDown={interactive ? handleDisplayKeyDown : undefined}\n testId={testId}\n >\n {displayContent}\n </Text>\n );\n};\n\nEditableText.displayName = 'EditableText';\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;AA+BA;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BG;AACI;AA8BL;;;;AAIE;AACD;;;AAKD;AACA;;;AAGA;;;AAGA;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AAEA;AAKA;;;AAEE;AACA;;AAEA;;;AAKE;AACA;;AAEA;AACF;AAIF;;;AAGI;AACA;;;AAGA;;AAKJ;;;AAGI;AACF;AAIF;AAGI;;;;;;;;;;AAUA;;;;;AAQJ;;;;;;AAME;;;AAMF;AACE;;AAEE;;;;AAKJ;AAEI;;AAEE;;AAEJ;AAIF;AAEI;;;;AAGO;;;;AAIP;;AAKJ;AACE;;;;;;;;;AASF;AACA;AACA;AACA;AACA;;;;;AAMI;AACA;AACA;;AAEA;AACD;;;;AAKC;AAiBM;AACF;;;AAMA;;AAOR;AACA;;AAGE;AACA;AACE;AACA;AACE;;AAGJ;AACA;AACE;;AAGJ;;;;;;AA0CF;AAEA;;"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * English defaults for every user-facing string EditableText renders. Override
3
+ * any subset by passing `labels` to `<EditableText>`; the rest fall back here.
4
+ */
5
+ const DEFAULT_EDITABLE_TEXT_LABELS = {
6
+ editLabel: 'Edit text',
7
+ };
8
+ /** Merge a partial `labels` override onto the English defaults. */
9
+ function resolveEditableTextLabels(overrides) {
10
+ return overrides
11
+ ? { ...DEFAULT_EDITABLE_TEXT_LABELS, ...overrides }
12
+ : DEFAULT_EDITABLE_TEXT_LABELS;
13
+ }
14
+
15
+ export { DEFAULT_EDITABLE_TEXT_LABELS, resolveEditableTextLabels };
16
+ //# sourceMappingURL=editableTextLabels.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"editableTextLabels.js","sources":["src/components/primitives/EditableText/editableTextLabels.ts"],"sourcesContent":["import type { EditableTextLabels } from './EditableText.types';\n\n/**\n * English defaults for every user-facing string EditableText renders. Override\n * any subset by passing `labels` to `<EditableText>`; the rest fall back here.\n */\nexport const DEFAULT_EDITABLE_TEXT_LABELS: EditableTextLabels = {\n editLabel: 'Edit text',\n};\n\n/** Merge a partial `labels` override onto the English defaults. */\nexport function resolveEditableTextLabels(\n overrides: Partial<EditableTextLabels> | undefined\n): EditableTextLabels {\n return overrides\n ? { ...DEFAULT_EDITABLE_TEXT_LABELS, ...overrides }\n : DEFAULT_EDITABLE_TEXT_LABELS;\n}\n"],"names":[],"mappings":"AAEA;;;AAGG;AACI,MAAM,4BAA4B,GAAuB;AAC9D,IAAA,SAAS,EAAE,WAAW;;AAGxB;AACM,SAAU,yBAAyB,CACvC,SAAkD,EAAA;AAElD,IAAA,OAAO;AACL,UAAE,EAAE,GAAG,4BAA4B,EAAE,GAAG,SAAS;UAC/C,4BAA4B;AAClC;;;;"}
package/dist/esm/index.js CHANGED
@@ -32,6 +32,8 @@ export { Checkbox } from './components/primitives/Checkbox/Checkbox.js';
32
32
  export { CheckboxGroup } from './components/primitives/Checkbox/CheckboxGroup.js';
33
33
  export { Code } from './components/primitives/Code/Code.js';
34
34
  export { Collapsible } from './components/primitives/Collapsible/Collapsible.js';
35
+ export { EditableText } from './components/primitives/EditableText/EditableText.js';
36
+ export { DEFAULT_EDITABLE_TEXT_LABELS, resolveEditableTextLabels } from './components/primitives/EditableText/editableTextLabels.js';
35
37
  export { HoverCard } from './components/primitives/HoverCard/HoverCard.js';
36
38
  export { Icon } from './components/primitives/Icon/Icon.js';
37
39
  export { IconButton } from './components/primitives/IconButton/IconButton.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -1,4 +1,4 @@
1
- /* Entangle UI dark theme — token export (v0.12.0) */
1
+ /* Entangle UI dark theme — token export (v0.13.0) */
2
2
  /* Generated by scripts/export-tokens.ts — do not edit by hand. */
3
3
  :root {
4
4
  --etui-color-accent-error: #f44336;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://design-tokens.org/schema.json",
3
3
  "$description": "Entangle UI design tokens",
4
- "$version": "0.12.0",
4
+ "$version": "0.13.0",
5
5
  "themes": {
6
6
  "dark": {
7
7
  "colors": {
@@ -1,4 +1,4 @@
1
- /* Entangle UI light theme — token export (v0.12.0) */
1
+ /* Entangle UI light theme — token export (v0.13.0) */
2
2
  /* Generated by scripts/export-tokens.ts — do not edit by hand. */
3
3
  .etui-theme-light {
4
4
  --etui-color-accent-error: #d32f2f;
@@ -17,11 +17,17 @@ interface GizmoOrientation {
17
17
  /**
18
18
  * Orbital rotation delta during a drag gesture.
19
19
  * The app applies this to its camera orbit controller.
20
+ *
21
+ * Sign convention (before any `invertYaw` / `invertPitch`): dragging the
22
+ * pointer right — or pressing `ArrowRight` — emits a positive `deltaYaw`;
23
+ * dragging up — or `ArrowUp` — emits a positive `deltaPitch`. Which way the
24
+ * camera actually turns is the consumer's choice; flip it with the
25
+ * `invertYaw` / `invertPitch` props if the motion feels mirrored.
20
26
  */
21
27
  interface OrbitDelta {
22
- /** Yaw change in degrees (horizontal drag) */
28
+ /** Yaw change in degrees. Positive = pointer dragged right / ArrowRight. */
23
29
  deltaYaw: number;
24
- /** Pitch change in degrees (vertical drag) */
30
+ /** Pitch change in degrees. Positive = pointer dragged up / ArrowUp. */
25
31
  deltaPitch: number;
26
32
  }
27
33
  /**
@@ -109,7 +115,24 @@ interface ViewportGizmoBaseProps extends Omit<BaseComponent, 'onChange'> {
109
115
  * @default true
110
116
  */
111
117
  constrainPitch?: boolean;
112
- /** Called continuously while the user drags to orbit. */
118
+ /**
119
+ * Invert the horizontal orbit direction. When true, the emitted `deltaYaw`
120
+ * sign is flipped, so the consumer's camera turns the opposite way for the
121
+ * same drag / arrow-key gesture. Use this when the orbit feels mirrored
122
+ * relative to your camera controller (e.g. Three.js OrbitControls).
123
+ * @default false
124
+ */
125
+ invertYaw?: boolean;
126
+ /**
127
+ * Invert the vertical orbit direction. When true, the emitted `deltaPitch`
128
+ * sign is flipped (dragging up turns the camera down).
129
+ * @default false
130
+ */
131
+ invertPitch?: boolean;
132
+ /**
133
+ * Called continuously while the user drags to orbit.
134
+ * See {@link OrbitDelta} for the delta sign convention.
135
+ */
113
136
  onOrbit?: (delta: OrbitDelta) => void;
114
137
  /** Called when the user finishes an orbit drag. */
115
138
  onOrbitEnd?: (finalOrientation: GizmoOrientation) => void;
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { EditableTextProps } from './EditableText.types.js';
3
+
4
+ /**
5
+ * Text that looks like `<Text>` but turns into an inline editor when the user
6
+ * activates it — the editor-UI pattern for renaming layers, nodes, assets, and
7
+ * scene objects in place.
8
+ *
9
+ * The idle state renders a real `<Text>` (so it inherits every typography prop),
10
+ * and the editing state renders a chrome-less `<input>` that shares the exact
11
+ * same typography recipe, so the swap is visually seamless. The edit field
12
+ * auto-sizes to its content via a hidden sizer — no measurement effects.
13
+ *
14
+ * Editing commits on `Enter` (and on blur when `submitOnBlur`), cancels on
15
+ * `Escape`. `onChange` fires on commit only, with the new value.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * // Uncontrolled — click the text to rename in place
20
+ * <EditableText defaultValue="Untitled Layer" onChange={setName} />
21
+ *
22
+ * // Controlled, heading-styled, double-click to edit (rename convention)
23
+ * <EditableText
24
+ * variant="heading"
25
+ * value={name}
26
+ * onChange={setName}
27
+ * activationMode="double"
28
+ * placeholder="Name this node"
29
+ * />
30
+ * ```
31
+ */
32
+ declare const EditableText: {
33
+ ({ value, defaultValue, onChange, placeholder, as, variant, size, weight, color, lineHeight, align, truncate, mono, activationMode, disabled, readOnly, selectOnEdit, submitOnBlur, maxLength, labels: labelsProp, "aria-label": ariaLabel, onEditStart, onEditEnd, onKeyDown, className, style, testId, ref, }: EditableTextProps): React.ReactElement;
34
+ displayName: string;
35
+ };
36
+
37
+ export { EditableText };
@@ -0,0 +1,158 @@
1
+ import { TextElement, TextVariant, TextSize, TextWeight, TextColor, TextLineHeight, TextAlign } from '../Text/Text.js';
2
+ import { Prettify } from '../../../types/utilities.js';
3
+ import React from 'react';
4
+
5
+ /**
6
+ * How a pointer starts an edit session.
7
+ *
8
+ * Keyboard activation (Enter / F2 while the display is focused) always works,
9
+ * independent of this setting.
10
+ * - `single`: a single click on the text starts editing.
11
+ * - `double`: a double click starts editing; single clicks are left free for
12
+ * selection or row activation (the file/node-rename convention).
13
+ */
14
+ type EditableTextActivationMode = 'single' | 'double';
15
+ /**
16
+ * Why an edit session ended.
17
+ * - `commit`: the draft was accepted (Enter, or blur with `submitOnBlur`).
18
+ * - `cancel`: the draft was discarded (Escape, or blur without `submitOnBlur`).
19
+ */
20
+ type EditableTextEndReason = 'commit' | 'cancel';
21
+ /**
22
+ * Every user-facing string EditableText renders. Override any subset via the
23
+ * `labels` prop; the rest fall back to {@link DEFAULT_EDITABLE_TEXT_LABELS}.
24
+ */
25
+ interface EditableTextLabels {
26
+ /**
27
+ * Accessible name for the edit field while editing, and the fallback
28
+ * accessible name for the display element when the value is empty (and no
29
+ * `placeholder` is set). An explicit `aria-label` prop still wins.
30
+ * @default 'Edit text'
31
+ */
32
+ editLabel: string;
33
+ }
34
+ /**
35
+ * Imperative handle exposed through `ref`. Lets an external control (e.g. a
36
+ * toolbar "Rename" button) drive the edit session and focus.
37
+ */
38
+ interface EditableTextHandle {
39
+ /** Enter edit mode programmatically (no-op when disabled or read-only). */
40
+ edit: () => void;
41
+ /** Leave edit mode, committing the current draft. */
42
+ commit: () => void;
43
+ /** Leave edit mode, discarding the current draft. */
44
+ cancel: () => void;
45
+ /** Focus the edit field while editing, otherwise the display element. */
46
+ focus: () => void;
47
+ /** Whether an edit session is currently active. */
48
+ isEditing: () => boolean;
49
+ /** The root DOM element for the current state (display element or input). */
50
+ getElement: () => HTMLElement | null;
51
+ }
52
+ interface EditableTextBaseProps {
53
+ /**
54
+ * The committed text value (controlled).
55
+ */
56
+ value?: string;
57
+ /**
58
+ * Initial committed value (uncontrolled).
59
+ */
60
+ defaultValue?: string;
61
+ /**
62
+ * Called with the new value when an edit is committed. Fires on commit only
63
+ * (Enter / blur), not on every keystroke.
64
+ */
65
+ onChange?: (value: string) => void;
66
+ /**
67
+ * Placeholder shown when the value is empty, both in the display and the edit
68
+ * field. Rendered in the muted placeholder color.
69
+ */
70
+ placeholder?: string;
71
+ /**
72
+ * HTML element rendered for the display (idle) state.
73
+ * @default 'span'
74
+ */
75
+ as?: TextElement;
76
+ /**
77
+ * Semantic typography variant, matching `Text`.
78
+ * @default 'body'
79
+ */
80
+ variant?: TextVariant;
81
+ /** Text size, matching `Text`. Overrides the variant size. */
82
+ size?: TextSize;
83
+ /** Text weight, matching `Text`. Overrides the variant weight. */
84
+ weight?: TextWeight;
85
+ /**
86
+ * Text color, matching `Text`.
87
+ * @default 'primary'
88
+ */
89
+ color?: TextColor;
90
+ /** Line height, matching `Text`. */
91
+ lineHeight?: TextLineHeight;
92
+ /** Text alignment, matching `Text`. */
93
+ align?: TextAlign;
94
+ /** Truncate the display text with an ellipsis on overflow. */
95
+ truncate?: boolean;
96
+ /** Use the monospace font family (also implied by `variant="code"`). */
97
+ mono?: boolean;
98
+ /**
99
+ * How a pointer starts an edit session.
100
+ * @default 'single'
101
+ */
102
+ activationMode?: EditableTextActivationMode;
103
+ /**
104
+ * Disable the field entirely — no editing, no hover affordance, not
105
+ * focusable.
106
+ * @default false
107
+ */
108
+ disabled?: boolean;
109
+ /**
110
+ * Render the value but do not allow editing. Still selectable/focusable.
111
+ * @default false
112
+ */
113
+ readOnly?: boolean;
114
+ /**
115
+ * Select the whole value when an edit session starts.
116
+ * @default true
117
+ */
118
+ selectOnEdit?: boolean;
119
+ /**
120
+ * Commit the draft when the edit field loses focus. When `false`, blurring
121
+ * discards the draft instead.
122
+ * @default true
123
+ */
124
+ submitOnBlur?: boolean;
125
+ /** Maximum number of characters accepted by the edit field. */
126
+ maxLength?: number;
127
+ /**
128
+ * String overrides for localization. Pass a stable reference (memoize it) —
129
+ * it is low-frequency config.
130
+ */
131
+ labels?: Partial<EditableTextLabels>;
132
+ /**
133
+ * Explicit accessible name. Wins over the value/placeholder-derived name and
134
+ * over `labels.editLabel`.
135
+ */
136
+ 'aria-label'?: string;
137
+ /** Called when an edit session starts. */
138
+ onEditStart?: () => void;
139
+ /** Called when an edit session ends, with the reason. */
140
+ onEditEnd?: (reason: EditableTextEndReason) => void;
141
+ /** Key-down handler forwarded to the edit field (runs after built-ins). */
142
+ onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
143
+ /** Additional CSS class name applied to the current root element. */
144
+ className?: string;
145
+ /** Inline styles applied to the current root element. */
146
+ style?: React.CSSProperties;
147
+ /** Test identifier for automated testing. */
148
+ testId?: string;
149
+ /** Imperative handle — see {@link EditableTextHandle}. */
150
+ ref?: React.Ref<EditableTextHandle>;
151
+ }
152
+ /**
153
+ * Props for the EditableText component with a prettified type for better
154
+ * IntelliSense.
155
+ */
156
+ type EditableTextProps = Prettify<EditableTextBaseProps>;
157
+
158
+ export type { EditableTextActivationMode, EditableTextBaseProps, EditableTextEndReason, EditableTextHandle, EditableTextLabels, EditableTextProps };
@@ -0,0 +1,11 @@
1
+ import { EditableTextLabels } from './EditableText.types.js';
2
+
3
+ /**
4
+ * English defaults for every user-facing string EditableText renders. Override
5
+ * any subset by passing `labels` to `<EditableText>`; the rest fall back here.
6
+ */
7
+ declare const DEFAULT_EDITABLE_TEXT_LABELS: EditableTextLabels;
8
+ /** Merge a partial `labels` override onto the English defaults. */
9
+ declare function resolveEditableTextLabels(overrides: Partial<EditableTextLabels> | undefined): EditableTextLabels;
10
+
11
+ export { DEFAULT_EDITABLE_TEXT_LABELS, resolveEditableTextLabels };
@@ -44,6 +44,9 @@ export { Code } from './components/primitives/Code/Code.js';
44
44
  export { CodeProps, CodeSize } from './components/primitives/Code/Code.types.js';
45
45
  export { Collapsible } from './components/primitives/Collapsible/Collapsible.js';
46
46
  export { CollapsibleProps, CollapsibleSize } from './components/primitives/Collapsible/Collapsible.types.js';
47
+ export { EditableText } from './components/primitives/EditableText/EditableText.js';
48
+ export { DEFAULT_EDITABLE_TEXT_LABELS, resolveEditableTextLabels } from './components/primitives/EditableText/editableTextLabels.js';
49
+ export { EditableTextActivationMode, EditableTextBaseProps, EditableTextEndReason, EditableTextHandle, EditableTextLabels, EditableTextProps } from './components/primitives/EditableText/EditableText.types.js';
47
50
  export { HoverCard } from './components/primitives/HoverCard/HoverCard.js';
48
51
  export { HoverCardBaseProps, HoverCardContentBaseProps, HoverCardContentProps, HoverCardContextValue, HoverCardProps, HoverCardTriggerBaseProps, HoverCardTriggerProps } from './components/primitives/HoverCard/HoverCard.types.js';
49
52
  export { Icon, IconProps, IconSize } from './components/primitives/Icon/Icon.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangle-ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "*.css",