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.
- package/CHANGELOG.md +8 -0
- package/dist/esm/assets/src/components/primitives/EditableText/EditableText.css.ts.vanilla-CrVGgD19.css +63 -0
- package/dist/esm/components/editor/ViewportGizmo/ViewportGizmo.js +3 -1
- package/dist/esm/components/editor/ViewportGizmo/ViewportGizmo.js.map +1 -1
- package/dist/esm/components/editor/ViewportGizmo/useGizmoInteraction.js +21 -27
- package/dist/esm/components/editor/ViewportGizmo/useGizmoInteraction.js.map +1 -1
- package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbEllipsis.js +1 -0
- package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbEllipsis.js.map +1 -1
- package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbItem.js +1 -0
- package/dist/esm/components/navigation/Breadcrumbs/BreadcrumbItem.js.map +1 -1
- package/dist/esm/components/navigation/PathBar/PathBarSiblingMenu.js +1 -0
- package/dist/esm/components/navigation/PathBar/PathBarSiblingMenu.js.map +1 -1
- package/dist/esm/components/primitives/EditableText/EditableText.css.js +11 -0
- package/dist/esm/components/primitives/EditableText/EditableText.css.js.map +1 -0
- package/dist/esm/components/primitives/EditableText/EditableText.js +208 -0
- package/dist/esm/components/primitives/EditableText/EditableText.js.map +1 -0
- package/dist/esm/components/primitives/EditableText/editableTextLabels.js +16 -0
- package/dist/esm/components/primitives/EditableText/editableTextLabels.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/tokens/tokens.dark.css +1 -1
- package/dist/tokens/tokens.json +1 -1
- package/dist/tokens/tokens.light.css +1 -1
- package/dist/types/components/editor/ViewportGizmo/ViewportGizmo.types.d.ts +26 -3
- package/dist/types/components/primitives/EditableText/EditableText.d.ts +37 -0
- package/dist/types/components/primitives/EditableText/EditableText.types.d.ts +158 -0
- package/dist/types/components/primitives/EditableText/editableTextLabels.d.ts +11 -0
- package/dist/types/index.d.ts +3 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
emitOrbit(-step, 0);
|
|
170
176
|
handled = true;
|
|
171
177
|
}
|
|
172
178
|
break;
|
|
173
179
|
case 'ArrowRight':
|
|
174
180
|
if (allowOrbit) {
|
|
175
|
-
|
|
181
|
+
emitOrbit(step, 0);
|
|
176
182
|
handled = true;
|
|
177
183
|
}
|
|
178
184
|
break;
|
|
179
185
|
case 'ArrowUp':
|
|
180
186
|
if (allowOrbit) {
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":"
|
|
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":"
|
|
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":"
|
|
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';
|
package/dist/esm/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/dist/tokens/tokens.json
CHANGED
|
@@ -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
|
|
28
|
+
/** Yaw change in degrees. Positive = pointer dragged right / ArrowRight. */
|
|
23
29
|
deltaYaw: number;
|
|
24
|
-
/** Pitch change in degrees
|
|
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
|
-
/**
|
|
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 };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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';
|