@vsreact/core 0.0.4 → 0.0.5
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/dist/controls.d.ts +35 -0
- package/dist/controls.js +44 -1
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +10 -0
- package/dist/hostConfig.js +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/overlay.d.ts +9 -0
- package/dist/overlay.js +47 -0
- package/dist/primitives.d.ts +11 -0
- package/dist/render.d.ts +2 -1
- package/dist/render.js +5 -2
- package/package.json +2 -2
- package/src/controls.test.tsx +112 -1
- package/src/controls.tsx +151 -1
- package/src/hooks.ts +12 -0
- package/src/hostConfig.ts +1 -0
- package/src/index.ts +7 -2
- package/src/overlay.tsx +70 -0
- package/src/primitives.tsx +11 -0
- package/src/render.tsx +12 -2
package/dist/controls.d.ts
CHANGED
|
@@ -135,3 +135,38 @@ export interface GenericEditorProps {
|
|
|
135
135
|
/** The zero-effort editor: one knob per APVTS parameter, laid out in
|
|
136
136
|
rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
|
|
137
137
|
export declare function GenericEditor({ columns, size, trackColor, valueColor }: GenericEditorProps): import("react").JSX.Element;
|
|
138
|
+
export interface SelectProps {
|
|
139
|
+
options: string[];
|
|
140
|
+
index: number;
|
|
141
|
+
/** Trigger width; the menu matches it. Default 160. */
|
|
142
|
+
width?: number;
|
|
143
|
+
label?: string;
|
|
144
|
+
disabled?: boolean;
|
|
145
|
+
trackColor?: string;
|
|
146
|
+
menuColor?: string;
|
|
147
|
+
activeColor?: string;
|
|
148
|
+
textColor?: string;
|
|
149
|
+
activeTextColor?: string;
|
|
150
|
+
/** Menu scrolls beyond this height. Default 190. */
|
|
151
|
+
maxMenuHeight?: number;
|
|
152
|
+
onChange: (index: number) => void;
|
|
153
|
+
}
|
|
154
|
+
/** A dropdown: the menu renders in the overlay layer, positioned under
|
|
155
|
+
the trigger via onLayout, with a click-away backdrop and a scrolling
|
|
156
|
+
option list. */
|
|
157
|
+
export declare function Select({ options, index, width, label, disabled, trackColor, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight, onChange, }: SelectProps): import("react").JSX.Element;
|
|
158
|
+
export interface ParamSelectProps {
|
|
159
|
+
paramId: string;
|
|
160
|
+
options: string[];
|
|
161
|
+
width?: number;
|
|
162
|
+
label?: string;
|
|
163
|
+
trackColor?: string;
|
|
164
|
+
menuColor?: string;
|
|
165
|
+
activeColor?: string;
|
|
166
|
+
textColor?: string;
|
|
167
|
+
activeTextColor?: string;
|
|
168
|
+
maxMenuHeight?: number;
|
|
169
|
+
}
|
|
170
|
+
/** A Select bound to a choice-style APVTS parameter (same value↔index
|
|
171
|
+
mapping as ParamSegmented). */
|
|
172
|
+
export declare function ParamSelect({ paramId, options, label, ...rest }: ParamSelectProps): import("react").JSX.Element;
|
package/dist/controls.js
CHANGED
|
@@ -2,10 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
// The built-in VST controls — knobs, sliders, toggles, XY pads, segmented
|
|
3
3
|
// switches — drawn by the VSReacT painter and driven by drag gestures.
|
|
4
4
|
// Each has a Param* variant bound to an APVTS parameter.
|
|
5
|
-
import { useRef } from "react";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
6
|
import { View, Text } from "./primitives";
|
|
7
7
|
import { useParameter, useParameterList } from "./parameters";
|
|
8
8
|
import { useSpring } from "./animation";
|
|
9
|
+
import { useLayoutRect } from "./hooks";
|
|
10
|
+
import { useOverlay } from "./overlay";
|
|
9
11
|
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
10
12
|
/** Vertical-drag-to-value mapping shared by Knob (and tested in isolation). */
|
|
11
13
|
export function dragToValue(startValue, dy, sensitivity = 0.005) {
|
|
@@ -137,3 +139,44 @@ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }
|
|
|
137
139
|
rows.push(params.slice(i, i + Math.max(1, columns)));
|
|
138
140
|
return (_jsx(View, { className: "flex-1 flex-col items-center justify-center gap-6 p-4", children: rows.map((row, index) => (_jsx(View, { className: "flex-row gap-7", children: row.map((param) => (_jsx(ParamKnob, { paramId: param.id, size: size, trackColor: trackColor, valueColor: valueColor }, param.id))) }, index))) }));
|
|
139
141
|
}
|
|
142
|
+
const SELECT_ROW_HEIGHT = 30;
|
|
143
|
+
/** A dropdown: the menu renders in the overlay layer, positioned under
|
|
144
|
+
the trigger via onLayout, with a click-away backdrop and a scrolling
|
|
145
|
+
option list. */
|
|
146
|
+
export function Select({ options, index, width = 160, label, disabled, trackColor = "#2A2F27", menuColor = "#20241F", activeColor = "#C6F135", textColor = "#d4d4d8", activeTextColor = "#09090b", maxMenuHeight = 190, onChange, }) {
|
|
147
|
+
const [open, setOpen] = useState(false);
|
|
148
|
+
const [rect, onLayout] = useLayoutRect();
|
|
149
|
+
const overlay = useOverlay();
|
|
150
|
+
const current = Math.min(options.length - 1, Math.max(0, index));
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!open || rect === null) {
|
|
153
|
+
overlay.hide();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const menuHeight = Math.min(options.length * SELECT_ROW_HEIGHT + 8, maxMenuHeight);
|
|
157
|
+
overlay.show(_jsx(View, { className: "absolute inset-0", onClick: () => setOpen(false), children: _jsx(View, { className: "absolute rounded-lg border shadow-lg overflow-hidden", style: {
|
|
158
|
+
left: rect.x,
|
|
159
|
+
top: rect.y + rect.height + 4,
|
|
160
|
+
width: rect.width,
|
|
161
|
+
backgroundColor: menuColor,
|
|
162
|
+
borderColor: "#00000066",
|
|
163
|
+
}, children: _jsx(View, { className: "overflow-y-scroll p-[4] gap-[2]", style: { height: menuHeight }, children: options.map((option, i) => (_jsx(View, { className: "px-3 py-[6] rounded cursor-pointer hover:bg-white/10", style: { backgroundColor: i === current ? activeColor : "#00000000" }, onClick: () => {
|
|
164
|
+
onChange(i);
|
|
165
|
+
setOpen(false);
|
|
166
|
+
}, children: _jsx(Text, { className: "text-[12] font-medium", style: { color: i === current ? activeTextColor : textColor }, children: option }) }, `${option}-${i}`))) }) }) }));
|
|
167
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
+
}, [open, rect, options, current, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight]);
|
|
169
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `flex-row items-center justify-between rounded-lg border px-3 py-[8] ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, backgroundColor: trackColor, borderColor: "#00000055" }, onLayout: onLayout, onClick: disabled ? undefined : () => setOpen((v) => !v), children: [_jsx(Text, { className: "text-[12] font-medium", style: { color: textColor }, children: options[current] ?? "" }), _jsx(Text, { className: "text-[9]", style: { color: textColor, opacity: 0.7 }, children: open ? "▲" : "▼" })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
170
|
+
}
|
|
171
|
+
/** A Select bound to a choice-style APVTS parameter (same value↔index
|
|
172
|
+
mapping as ParamSegmented). */
|
|
173
|
+
export function ParamSelect({ paramId, options, label, ...rest }) {
|
|
174
|
+
const param = useParameter(paramId);
|
|
175
|
+
const count = Math.max(1, options.length);
|
|
176
|
+
const index = Math.round(param.value * (count - 1));
|
|
177
|
+
return (_jsx(Select, { options: options, index: index, label: label ?? param.name.toUpperCase(), onChange: (i) => {
|
|
178
|
+
param.begin();
|
|
179
|
+
param.set(count <= 1 ? 0 : i / (count - 1));
|
|
180
|
+
param.end();
|
|
181
|
+
}, ...rest }));
|
|
182
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import type { LayoutRect } from "./primitives";
|
|
2
|
+
/**
|
|
3
|
+
* Captures a node's root-space rect from its onLayout prop:
|
|
4
|
+
*
|
|
5
|
+
* const [rect, onLayout] = useLayoutRect();
|
|
6
|
+
* <View onLayout={onLayout} /> // rect updates whenever layout moves it
|
|
7
|
+
*/
|
|
8
|
+
export declare function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect) => void];
|
|
1
9
|
/**
|
|
2
10
|
* Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
|
|
3
11
|
* of the component. The handler can close over fresh state — it is kept
|
package/dist/hooks.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// Convenience hooks over the native bridge.
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { native } from "./native";
|
|
4
|
+
/**
|
|
5
|
+
* Captures a node's root-space rect from its onLayout prop:
|
|
6
|
+
*
|
|
7
|
+
* const [rect, onLayout] = useLayoutRect();
|
|
8
|
+
* <View onLayout={onLayout} /> // rect updates whenever layout moves it
|
|
9
|
+
*/
|
|
10
|
+
export function useLayoutRect() {
|
|
11
|
+
const [rect, setRect] = useState(null);
|
|
12
|
+
return [rect, setRect];
|
|
13
|
+
}
|
|
4
14
|
/**
|
|
5
15
|
* Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
|
|
6
16
|
* of the component. The handler can close over fresh state — it is kept
|
package/dist/hostConfig.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ export { View, Text, Image, TextInput, NativeView } from "./primitives";
|
|
|
4
4
|
export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, } from "./primitives";
|
|
5
5
|
export { render, unmount } from "./render";
|
|
6
6
|
export { native } from "./native";
|
|
7
|
-
export { useNativeEvent, useDebounced } from "./hooks";
|
|
7
|
+
export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
|
|
8
|
+
export { useOverlay, OverlayLayer } from "./overlay";
|
|
8
9
|
export { configureTheme, tw } from "./tw";
|
|
9
10
|
export type { Style, ResolvedClasses } from "./tw";
|
|
10
11
|
export { cx } from "./cx";
|
|
@@ -13,8 +14,8 @@ export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
|
|
|
13
14
|
export type { TweenOptions, SpringOptions, EasingFn } from "./animation";
|
|
14
15
|
export { useParameter, useParameterList } from "./parameters";
|
|
15
16
|
export type { ParameterState, ParameterHandle, ParameterInfo } from "./parameters";
|
|
16
|
-
export { Knob, Slider, Toggle, XYPad, Segmented, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, dragToValue, } from "./controls";
|
|
17
|
-
export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, } from "./controls";
|
|
17
|
+
export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
|
|
18
|
+
export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, SelectProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, ParamSelectProps, } from "./controls";
|
|
18
19
|
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
19
20
|
export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
20
|
-
export type { DragEventPayload } from "./primitives";
|
|
21
|
+
export type { DragEventPayload, LayoutRect } from "./primitives";
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,11 @@ import "./bridge";
|
|
|
5
5
|
export { View, Text, Image, TextInput, NativeView } from "./primitives";
|
|
6
6
|
export { render, unmount } from "./render";
|
|
7
7
|
export { native } from "./native";
|
|
8
|
-
export { useNativeEvent, useDebounced } from "./hooks";
|
|
8
|
+
export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
|
|
9
|
+
export { useOverlay, OverlayLayer } from "./overlay";
|
|
9
10
|
export { configureTheme, tw } from "./tw";
|
|
10
11
|
export { cx } from "./cx";
|
|
11
12
|
export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
|
|
12
13
|
export { useParameter, useParameterList } from "./parameters";
|
|
13
|
-
export { Knob, Slider, Toggle, XYPad, Segmented, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, dragToValue, } from "./controls";
|
|
14
|
+
export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKnob, ParamSlider, ParamToggle, ParamXYPad, ParamSegmented, ParamSelect, dragToValue, } from "./controls";
|
|
14
15
|
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** A per-component slot in the overlay layer. show() replaces the slot's
|
|
3
|
+
content; hide() removes it; unmounting cleans up automatically. */
|
|
4
|
+
export declare function useOverlay(): {
|
|
5
|
+
show: (node: ReactNode) => void;
|
|
6
|
+
hide: () => void;
|
|
7
|
+
};
|
|
8
|
+
/** Mounted automatically by render() as the last sibling of your app. */
|
|
9
|
+
export declare function OverlayLayer(): import("react").JSX.Element;
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// The overlay layer — content that paints above everything else (menus,
|
|
3
|
+
// tooltips, modals). render() mounts <OverlayLayer/> after your app
|
|
4
|
+
// automatically, so painting order puts overlays on top and hit-testing
|
|
5
|
+
// reaches them first. Position entries absolutely using rects from
|
|
6
|
+
// onLayout / useLayoutRect.
|
|
7
|
+
import { Fragment, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
8
|
+
let entries = [];
|
|
9
|
+
const subscribers = new Set();
|
|
10
|
+
let nextOverlayKey = 1;
|
|
11
|
+
function emit() {
|
|
12
|
+
for (const subscriber of subscribers)
|
|
13
|
+
subscriber();
|
|
14
|
+
}
|
|
15
|
+
function setEntry(key, node) {
|
|
16
|
+
entries = [...entries.filter((e) => e.key !== key), { key, node }];
|
|
17
|
+
emit();
|
|
18
|
+
}
|
|
19
|
+
function clearEntry(key) {
|
|
20
|
+
if (!entries.some((e) => e.key === key))
|
|
21
|
+
return;
|
|
22
|
+
entries = entries.filter((e) => e.key !== key);
|
|
23
|
+
emit();
|
|
24
|
+
}
|
|
25
|
+
/** A per-component slot in the overlay layer. show() replaces the slot's
|
|
26
|
+
content; hide() removes it; unmounting cleans up automatically. */
|
|
27
|
+
export function useOverlay() {
|
|
28
|
+
const key = useRef(0);
|
|
29
|
+
if (key.current === 0)
|
|
30
|
+
key.current = nextOverlayKey++;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const k = key.current;
|
|
33
|
+
return () => clearEntry(k);
|
|
34
|
+
}, []);
|
|
35
|
+
return {
|
|
36
|
+
show: useCallback((node) => setEntry(key.current, node), []),
|
|
37
|
+
hide: useCallback(() => clearEntry(key.current), []),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Mounted automatically by render() as the last sibling of your app. */
|
|
41
|
+
export function OverlayLayer() {
|
|
42
|
+
const list = useSyncExternalStore((onStoreChange) => {
|
|
43
|
+
subscribers.add(onStoreChange);
|
|
44
|
+
return () => subscribers.delete(onStoreChange);
|
|
45
|
+
}, () => entries);
|
|
46
|
+
return (_jsx(_Fragment, { children: list.map((entry) => (_jsx(Fragment, { children: entry.node }, entry.key))) }));
|
|
47
|
+
}
|
package/dist/primitives.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface DragEventPayload {
|
|
|
8
8
|
x: number;
|
|
9
9
|
y: number;
|
|
10
10
|
}
|
|
11
|
+
/** A node's laid-out rect in root coordinates (scroll-adjusted). */
|
|
12
|
+
export interface LayoutRect {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
11
18
|
export interface CommonProps {
|
|
12
19
|
className?: string;
|
|
13
20
|
style?: Style;
|
|
@@ -22,6 +29,9 @@ export interface CommonProps {
|
|
|
22
29
|
onDragStart?: (e: DragEventPayload) => void;
|
|
23
30
|
onDrag?: (e: DragEventPayload) => void;
|
|
24
31
|
onDragEnd?: (e: DragEventPayload) => void;
|
|
32
|
+
/** Fires after layout whenever this node's root-space rect changes —
|
|
33
|
+
the foundation for popovers, menus, and tooltips. */
|
|
34
|
+
onLayout?: (rect: LayoutRect) => void;
|
|
25
35
|
}
|
|
26
36
|
export declare function View(props: CommonProps): import("react").ReactElement<CommonProps, string | import("react").JSXElementConstructor<any>>;
|
|
27
37
|
export interface TextProps extends Omit<CommonProps, "children"> {
|
|
@@ -62,6 +72,7 @@ export declare function TextInput({ onChange, onSubmit, ...rest }: TextInputProp
|
|
|
62
72
|
onDragStart?: ((e: DragEventPayload) => void) | undefined;
|
|
63
73
|
onDrag?: ((e: DragEventPayload) => void) | undefined;
|
|
64
74
|
onDragEnd?: ((e: DragEventPayload) => void) | undefined;
|
|
75
|
+
onLayout?: ((rect: LayoutRect) => void) | undefined;
|
|
65
76
|
}, string | import("react").JSXElementConstructor<any>>;
|
|
66
77
|
export interface NativeViewProps extends CommonProps {
|
|
67
78
|
/** Id of a component factory registered in the C++ NativeRegistry. */
|
package/dist/render.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
/** Mounts (or re-renders) the app into the plugin window.
|
|
2
|
+
/** Mounts (or re-renders) the app into the plugin window. The overlay
|
|
3
|
+
layer (menus, tooltips) mounts after the app so it paints on top. */
|
|
3
4
|
export declare function render(element: ReactNode): void;
|
|
4
5
|
/** Unmounts the current app (used by hot reload teardown). */
|
|
5
6
|
export declare function unmount(): void;
|
package/dist/render.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
2
|
import Reconciler from "react-reconciler";
|
|
2
3
|
import { LegacyRoot } from "react-reconciler/constants";
|
|
3
4
|
import { hostConfig } from "./hostConfig";
|
|
4
5
|
import { flushOps } from "./bridge";
|
|
6
|
+
import { OverlayLayer } from "./overlay";
|
|
5
7
|
const reconciler = Reconciler(hostConfig);
|
|
6
8
|
let container = null;
|
|
7
|
-
/** Mounts (or re-renders) the app into the plugin window.
|
|
9
|
+
/** Mounts (or re-renders) the app into the plugin window. The overlay
|
|
10
|
+
layer (menus, tooltips) mounts after the app so it paints on top. */
|
|
8
11
|
export function render(element) {
|
|
9
12
|
if (!container) {
|
|
10
13
|
container = reconciler.createContainer({}, LegacyRoot, null, false, null, "vsreact", (error) => console.error("[vsreact] recoverable error:", error), null);
|
|
11
14
|
}
|
|
12
|
-
reconciler.updateContainer(element, container, null, null);
|
|
15
|
+
reconciler.updateContainer(_jsxs(_Fragment, { children: [element, _jsx(OverlayLayer, {})] }), container, null, null);
|
|
13
16
|
flushOps();
|
|
14
17
|
}
|
|
15
18
|
/** Unmounts the current app (used by hot reload teardown). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vsreact/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"bugs": "https://github.com/N9RecordsTechnologiesIL/VSReacT/issues",
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
|
-
"url": "https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
|
|
23
|
+
"url": "git+https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
|
|
24
24
|
"directory": "vsreact/js"
|
|
25
25
|
},
|
|
26
26
|
"main": "dist/index.js",
|
package/src/controls.test.tsx
CHANGED
|
@@ -7,12 +7,13 @@ let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB
|
|
|
7
7
|
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
8
8
|
batches.push(JSON.parse(json));
|
|
9
9
|
};
|
|
10
|
-
|
|
10
|
+
const defaultNativeCall = (name: string, argsJson: string) => {
|
|
11
11
|
const args = JSON.parse(argsJson);
|
|
12
12
|
nativeCalls.push({ name, args });
|
|
13
13
|
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
14
14
|
return "null";
|
|
15
15
|
};
|
|
16
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
|
|
16
17
|
|
|
17
18
|
import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
|
|
18
19
|
|
|
@@ -33,6 +34,7 @@ beforeEach(() => {
|
|
|
33
34
|
batches.length = 0;
|
|
34
35
|
nativeCalls.length = 0;
|
|
35
36
|
paramGetResult = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
37
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
describe("vertical Slider", () => {
|
|
@@ -191,3 +193,112 @@ describe("Meter", () => {
|
|
|
191
193
|
expect(hot).toBeUndefined();
|
|
192
194
|
});
|
|
193
195
|
});
|
|
196
|
+
|
|
197
|
+
describe("onLayout + Select", () => {
|
|
198
|
+
/** Map child → parent from appendChild ops, to find a row by its text. */
|
|
199
|
+
const parentMap = () => {
|
|
200
|
+
const map = new Map<number, number>();
|
|
201
|
+
for (const op of allOps() as any[])
|
|
202
|
+
if (op[0] === "appendChild" || op[0] === "insertBefore") map.set(op[2], op[1]);
|
|
203
|
+
return map;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
test("onLayout registers a layout listener and receives the rect", () => {
|
|
207
|
+
const seen: any[] = [];
|
|
208
|
+
render(<Slider value={0.5} onChange={() => {}} />); // warm-up unrelated tree
|
|
209
|
+
unmount();
|
|
210
|
+
batches.length = 0;
|
|
211
|
+
|
|
212
|
+
const { View: V } = require("./index");
|
|
213
|
+
render(<V onLayout={(r: any) => seen.push(r)} className="w-4" />);
|
|
214
|
+
|
|
215
|
+
const props: any = opsNamed("setProps").find((op: any) =>
|
|
216
|
+
op[2]?.listeners?.includes("layout"),
|
|
217
|
+
);
|
|
218
|
+
expect(props).toBeDefined();
|
|
219
|
+
|
|
220
|
+
dispatch({
|
|
221
|
+
kind: "event",
|
|
222
|
+
nodeId: props[1],
|
|
223
|
+
type: "layout",
|
|
224
|
+
payload: { x: 5, y: 10, width: 160, height: 32 },
|
|
225
|
+
});
|
|
226
|
+
expect(seen).toEqual([{ x: 5, y: 10, width: 160, height: 32 }]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("Select opens a positioned overlay menu and selects on click", async () => {
|
|
230
|
+
const { Select } = require("./index");
|
|
231
|
+
const seen: number[] = [];
|
|
232
|
+
render(
|
|
233
|
+
<Select options={["SINE", "SAW", "SQR"]} index={0} width={160} onChange={(i: number) => seen.push(i)} />,
|
|
234
|
+
);
|
|
235
|
+
await new Promise((r) => setTimeout(r, 0)); // let effects flush
|
|
236
|
+
|
|
237
|
+
// trigger has both layout + click listeners
|
|
238
|
+
const trigger: any = opsNamed("setProps").find(
|
|
239
|
+
(op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
|
|
240
|
+
);
|
|
241
|
+
expect(trigger).toBeDefined();
|
|
242
|
+
|
|
243
|
+
// layout arrives, then the click opens the menu
|
|
244
|
+
dispatch({
|
|
245
|
+
kind: "event",
|
|
246
|
+
nodeId: trigger[1],
|
|
247
|
+
type: "layout",
|
|
248
|
+
payload: { x: 20, y: 40, width: 160, height: 33 },
|
|
249
|
+
});
|
|
250
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
251
|
+
dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
|
|
252
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
253
|
+
|
|
254
|
+
// menu panel positioned under the trigger, same width
|
|
255
|
+
const panel: any = opsNamed("setProps").find(
|
|
256
|
+
(op: any) => op[2]?.style?.top === 40 + 33 + 4 && op[2]?.style?.left === 20,
|
|
257
|
+
);
|
|
258
|
+
expect(panel).toBeDefined();
|
|
259
|
+
expect(panel[2].style.width).toBe(160);
|
|
260
|
+
|
|
261
|
+
// find the "SAW" row: rawtext -> Text -> row
|
|
262
|
+
const sawText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SAW");
|
|
263
|
+
expect(sawText).toBeDefined();
|
|
264
|
+
const parents = parentMap();
|
|
265
|
+
const rowId = parents.get(parents.get(sawText[1])!)!;
|
|
266
|
+
|
|
267
|
+
dispatch({ kind: "event", nodeId: rowId, type: "click" });
|
|
268
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
269
|
+
|
|
270
|
+
expect(seen).toEqual([1]);
|
|
271
|
+
// menu removed after selection
|
|
272
|
+
expect(allOps().some((op: any) => op[0] === "removeChild")).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("ParamSelect writes a begin/set/end gesture with the index mapping", async () => {
|
|
276
|
+
paramGetResult = { value: 0, text: "Sine", name: "Shape", label: "" };
|
|
277
|
+
const { ParamSelect } = require("./index");
|
|
278
|
+
render(<ParamSelect paramId="shape" options={["SINE", "SAW", "SQR"]} />);
|
|
279
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
280
|
+
|
|
281
|
+
const trigger: any = opsNamed("setProps").find(
|
|
282
|
+
(op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
|
|
283
|
+
);
|
|
284
|
+
dispatch({
|
|
285
|
+
kind: "event",
|
|
286
|
+
nodeId: trigger[1],
|
|
287
|
+
type: "layout",
|
|
288
|
+
payload: { x: 0, y: 0, width: 160, height: 33 },
|
|
289
|
+
});
|
|
290
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
291
|
+
dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
|
|
292
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
293
|
+
|
|
294
|
+
const sqrText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SQR");
|
|
295
|
+
const parents = parentMap();
|
|
296
|
+
const rowId = parents.get(parents.get(sqrText[1])!)!;
|
|
297
|
+
dispatch({ kind: "event", nodeId: rowId, type: "click" });
|
|
298
|
+
|
|
299
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
300
|
+
expect(set?.args).toEqual({ id: "shape", value: 1 });
|
|
301
|
+
expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
|
|
302
|
+
expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
package/src/controls.tsx
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// switches — drawn by the VSReacT painter and driven by drag gestures.
|
|
3
3
|
// Each has a Param* variant bound to an APVTS parameter.
|
|
4
4
|
|
|
5
|
-
import { useRef } from "react";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
6
|
import { View, Text } from "./primitives";
|
|
7
7
|
import { useParameter, useParameterList } from "./parameters";
|
|
8
8
|
import { useSpring } from "./animation";
|
|
9
|
+
import { useLayoutRect } from "./hooks";
|
|
10
|
+
import { useOverlay } from "./overlay";
|
|
9
11
|
|
|
10
12
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
11
13
|
|
|
@@ -564,3 +566,151 @@ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }
|
|
|
564
566
|
</View>
|
|
565
567
|
);
|
|
566
568
|
}
|
|
569
|
+
|
|
570
|
+
// ── Select ─────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
export interface SelectProps {
|
|
573
|
+
options: string[];
|
|
574
|
+
index: number;
|
|
575
|
+
/** Trigger width; the menu matches it. Default 160. */
|
|
576
|
+
width?: number;
|
|
577
|
+
label?: string;
|
|
578
|
+
disabled?: boolean;
|
|
579
|
+
trackColor?: string;
|
|
580
|
+
menuColor?: string;
|
|
581
|
+
activeColor?: string;
|
|
582
|
+
textColor?: string;
|
|
583
|
+
activeTextColor?: string;
|
|
584
|
+
/** Menu scrolls beyond this height. Default 190. */
|
|
585
|
+
maxMenuHeight?: number;
|
|
586
|
+
onChange: (index: number) => void;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const SELECT_ROW_HEIGHT = 30;
|
|
590
|
+
|
|
591
|
+
/** A dropdown: the menu renders in the overlay layer, positioned under
|
|
592
|
+
the trigger via onLayout, with a click-away backdrop and a scrolling
|
|
593
|
+
option list. */
|
|
594
|
+
export function Select({
|
|
595
|
+
options,
|
|
596
|
+
index,
|
|
597
|
+
width = 160,
|
|
598
|
+
label,
|
|
599
|
+
disabled,
|
|
600
|
+
trackColor = "#2A2F27",
|
|
601
|
+
menuColor = "#20241F",
|
|
602
|
+
activeColor = "#C6F135",
|
|
603
|
+
textColor = "#d4d4d8",
|
|
604
|
+
activeTextColor = "#09090b",
|
|
605
|
+
maxMenuHeight = 190,
|
|
606
|
+
onChange,
|
|
607
|
+
}: SelectProps) {
|
|
608
|
+
const [open, setOpen] = useState(false);
|
|
609
|
+
const [rect, onLayout] = useLayoutRect();
|
|
610
|
+
const overlay = useOverlay();
|
|
611
|
+
const current = Math.min(options.length - 1, Math.max(0, index));
|
|
612
|
+
|
|
613
|
+
useEffect(() => {
|
|
614
|
+
if (!open || rect === null) {
|
|
615
|
+
overlay.hide();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const menuHeight = Math.min(options.length * SELECT_ROW_HEIGHT + 8, maxMenuHeight);
|
|
620
|
+
|
|
621
|
+
overlay.show(
|
|
622
|
+
<View className="absolute inset-0" onClick={() => setOpen(false)}>
|
|
623
|
+
<View
|
|
624
|
+
className="absolute rounded-lg border shadow-lg overflow-hidden"
|
|
625
|
+
style={{
|
|
626
|
+
left: rect.x,
|
|
627
|
+
top: rect.y + rect.height + 4,
|
|
628
|
+
width: rect.width,
|
|
629
|
+
backgroundColor: menuColor,
|
|
630
|
+
borderColor: "#00000066",
|
|
631
|
+
}}
|
|
632
|
+
>
|
|
633
|
+
<View className="overflow-y-scroll p-[4] gap-[2]" style={{ height: menuHeight }}>
|
|
634
|
+
{options.map((option, i) => (
|
|
635
|
+
<View
|
|
636
|
+
key={`${option}-${i}`}
|
|
637
|
+
className="px-3 py-[6] rounded cursor-pointer hover:bg-white/10"
|
|
638
|
+
style={{ backgroundColor: i === current ? activeColor : "#00000000" }}
|
|
639
|
+
onClick={() => {
|
|
640
|
+
onChange(i);
|
|
641
|
+
setOpen(false);
|
|
642
|
+
}}
|
|
643
|
+
>
|
|
644
|
+
<Text
|
|
645
|
+
className="text-[12] font-medium"
|
|
646
|
+
style={{ color: i === current ? activeTextColor : textColor }}
|
|
647
|
+
>
|
|
648
|
+
{option}
|
|
649
|
+
</Text>
|
|
650
|
+
</View>
|
|
651
|
+
))}
|
|
652
|
+
</View>
|
|
653
|
+
</View>
|
|
654
|
+
</View>,
|
|
655
|
+
);
|
|
656
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
657
|
+
}, [open, rect, options, current, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight]);
|
|
658
|
+
|
|
659
|
+
return (
|
|
660
|
+
<View className="items-center gap-2">
|
|
661
|
+
<View
|
|
662
|
+
className={`flex-row items-center justify-between rounded-lg border px-3 py-[8] ${
|
|
663
|
+
disabled ? "opacity-40" : "cursor-pointer"
|
|
664
|
+
}`}
|
|
665
|
+
style={{ width, backgroundColor: trackColor, borderColor: "#00000055" }}
|
|
666
|
+
onLayout={onLayout}
|
|
667
|
+
onClick={disabled ? undefined : () => setOpen((v) => !v)}
|
|
668
|
+
>
|
|
669
|
+
<Text className="text-[12] font-medium" style={{ color: textColor }}>
|
|
670
|
+
{options[current] ?? ""}
|
|
671
|
+
</Text>
|
|
672
|
+
<Text className="text-[9]" style={{ color: textColor, opacity: 0.7 }}>
|
|
673
|
+
{open ? "▲" : "▼"}
|
|
674
|
+
</Text>
|
|
675
|
+
</View>
|
|
676
|
+
{label !== undefined ? (
|
|
677
|
+
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
678
|
+
) : null}
|
|
679
|
+
</View>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface ParamSelectProps {
|
|
684
|
+
paramId: string;
|
|
685
|
+
options: string[];
|
|
686
|
+
width?: number;
|
|
687
|
+
label?: string;
|
|
688
|
+
trackColor?: string;
|
|
689
|
+
menuColor?: string;
|
|
690
|
+
activeColor?: string;
|
|
691
|
+
textColor?: string;
|
|
692
|
+
activeTextColor?: string;
|
|
693
|
+
maxMenuHeight?: number;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** A Select bound to a choice-style APVTS parameter (same value↔index
|
|
697
|
+
mapping as ParamSegmented). */
|
|
698
|
+
export function ParamSelect({ paramId, options, label, ...rest }: ParamSelectProps) {
|
|
699
|
+
const param = useParameter(paramId);
|
|
700
|
+
const count = Math.max(1, options.length);
|
|
701
|
+
const index = Math.round(param.value * (count - 1));
|
|
702
|
+
|
|
703
|
+
return (
|
|
704
|
+
<Select
|
|
705
|
+
options={options}
|
|
706
|
+
index={index}
|
|
707
|
+
label={label ?? param.name.toUpperCase()}
|
|
708
|
+
onChange={(i) => {
|
|
709
|
+
param.begin();
|
|
710
|
+
param.set(count <= 1 ? 0 : i / (count - 1));
|
|
711
|
+
param.end();
|
|
712
|
+
}}
|
|
713
|
+
{...rest}
|
|
714
|
+
/>
|
|
715
|
+
);
|
|
716
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { native } from "./native";
|
|
5
|
+
import type { LayoutRect } from "./primitives";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Captures a node's root-space rect from its onLayout prop:
|
|
9
|
+
*
|
|
10
|
+
* const [rect, onLayout] = useLayoutRect();
|
|
11
|
+
* <View onLayout={onLayout} /> // rect updates whenever layout moves it
|
|
12
|
+
*/
|
|
13
|
+
export function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect) => void] {
|
|
14
|
+
const [rect, setRect] = useState<LayoutRect | null>(null);
|
|
15
|
+
return [rect, setRect];
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
19
|
* Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
|
package/src/hostConfig.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -13,7 +13,8 @@ export type {
|
|
|
13
13
|
} from "./primitives";
|
|
14
14
|
export { render, unmount } from "./render";
|
|
15
15
|
export { native } from "./native";
|
|
16
|
-
export { useNativeEvent, useDebounced } from "./hooks";
|
|
16
|
+
export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
|
|
17
|
+
export { useOverlay, OverlayLayer } from "./overlay";
|
|
17
18
|
export { configureTheme, tw } from "./tw";
|
|
18
19
|
export type { Style, ResolvedClasses } from "./tw";
|
|
19
20
|
export { cx } from "./cx";
|
|
@@ -28,12 +29,14 @@ export {
|
|
|
28
29
|
Toggle,
|
|
29
30
|
XYPad,
|
|
30
31
|
Segmented,
|
|
32
|
+
Select,
|
|
31
33
|
GenericEditor,
|
|
32
34
|
ParamKnob,
|
|
33
35
|
ParamSlider,
|
|
34
36
|
ParamToggle,
|
|
35
37
|
ParamXYPad,
|
|
36
38
|
ParamSegmented,
|
|
39
|
+
ParamSelect,
|
|
37
40
|
dragToValue,
|
|
38
41
|
} from "./controls";
|
|
39
42
|
export type {
|
|
@@ -42,13 +45,15 @@ export type {
|
|
|
42
45
|
ToggleProps,
|
|
43
46
|
XYPadProps,
|
|
44
47
|
SegmentedProps,
|
|
48
|
+
SelectProps,
|
|
45
49
|
GenericEditorProps,
|
|
46
50
|
ParamKnobProps,
|
|
47
51
|
ParamSliderProps,
|
|
48
52
|
ParamToggleProps,
|
|
49
53
|
ParamXYPadProps,
|
|
50
54
|
ParamSegmentedProps,
|
|
55
|
+
ParamSelectProps,
|
|
51
56
|
} from "./controls";
|
|
52
57
|
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
53
58
|
export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
54
|
-
export type { DragEventPayload } from "./primitives";
|
|
59
|
+
export type { DragEventPayload, LayoutRect } from "./primitives";
|
package/src/overlay.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The overlay layer — content that paints above everything else (menus,
|
|
2
|
+
// tooltips, modals). render() mounts <OverlayLayer/> after your app
|
|
3
|
+
// automatically, so painting order puts overlays on top and hit-testing
|
|
4
|
+
// reaches them first. Position entries absolutely using rects from
|
|
5
|
+
// onLayout / useLayoutRect.
|
|
6
|
+
|
|
7
|
+
import { Fragment, useCallback, useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
interface OverlayEntry {
|
|
10
|
+
key: number;
|
|
11
|
+
node: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let entries: OverlayEntry[] = [];
|
|
15
|
+
const subscribers = new Set<() => void>();
|
|
16
|
+
let nextOverlayKey = 1;
|
|
17
|
+
|
|
18
|
+
function emit(): void {
|
|
19
|
+
for (const subscriber of subscribers) subscriber();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setEntry(key: number, node: ReactNode): void {
|
|
23
|
+
entries = [...entries.filter((e) => e.key !== key), { key, node }];
|
|
24
|
+
emit();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clearEntry(key: number): void {
|
|
28
|
+
if (!entries.some((e) => e.key === key)) return;
|
|
29
|
+
entries = entries.filter((e) => e.key !== key);
|
|
30
|
+
emit();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A per-component slot in the overlay layer. show() replaces the slot's
|
|
34
|
+
content; hide() removes it; unmounting cleans up automatically. */
|
|
35
|
+
export function useOverlay(): {
|
|
36
|
+
show: (node: ReactNode) => void;
|
|
37
|
+
hide: () => void;
|
|
38
|
+
} {
|
|
39
|
+
const key = useRef(0);
|
|
40
|
+
if (key.current === 0) key.current = nextOverlayKey++;
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const k = key.current;
|
|
44
|
+
return () => clearEntry(k);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
show: useCallback((node: ReactNode) => setEntry(key.current, node), []),
|
|
49
|
+
hide: useCallback(() => clearEntry(key.current), []),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Mounted automatically by render() as the last sibling of your app. */
|
|
54
|
+
export function OverlayLayer() {
|
|
55
|
+
const list = useSyncExternalStore(
|
|
56
|
+
(onStoreChange) => {
|
|
57
|
+
subscribers.add(onStoreChange);
|
|
58
|
+
return () => subscribers.delete(onStoreChange);
|
|
59
|
+
},
|
|
60
|
+
() => entries,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
{list.map((entry) => (
|
|
66
|
+
<Fragment key={entry.key}>{entry.node}</Fragment>
|
|
67
|
+
))}
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
package/src/primitives.tsx
CHANGED
|
@@ -10,6 +10,14 @@ export interface DragEventPayload {
|
|
|
10
10
|
y: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** A node's laid-out rect in root coordinates (scroll-adjusted). */
|
|
14
|
+
export interface LayoutRect {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export interface CommonProps {
|
|
14
22
|
className?: string;
|
|
15
23
|
style?: Style;
|
|
@@ -24,6 +32,9 @@ export interface CommonProps {
|
|
|
24
32
|
onDragStart?: (e: DragEventPayload) => void;
|
|
25
33
|
onDrag?: (e: DragEventPayload) => void;
|
|
26
34
|
onDragEnd?: (e: DragEventPayload) => void;
|
|
35
|
+
/** Fires after layout whenever this node's root-space rect changes —
|
|
36
|
+
the foundation for popovers, menus, and tooltips. */
|
|
37
|
+
onLayout?: (rect: LayoutRect) => void;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
export function View(props: CommonProps) {
|
package/src/render.tsx
CHANGED
|
@@ -3,12 +3,14 @@ import { LegacyRoot } from "react-reconciler/constants";
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { hostConfig } from "./hostConfig";
|
|
5
5
|
import { flushOps } from "./bridge";
|
|
6
|
+
import { OverlayLayer } from "./overlay";
|
|
6
7
|
|
|
7
8
|
const reconciler = Reconciler(hostConfig as any);
|
|
8
9
|
|
|
9
10
|
let container: ReturnType<typeof reconciler.createContainer> | null = null;
|
|
10
11
|
|
|
11
|
-
/** Mounts (or re-renders) the app into the plugin window.
|
|
12
|
+
/** Mounts (or re-renders) the app into the plugin window. The overlay
|
|
13
|
+
layer (menus, tooltips) mounts after the app so it paints on top. */
|
|
12
14
|
export function render(element: ReactNode): void {
|
|
13
15
|
if (!container) {
|
|
14
16
|
container = reconciler.createContainer(
|
|
@@ -23,7 +25,15 @@ export function render(element: ReactNode): void {
|
|
|
23
25
|
);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
reconciler.updateContainer(
|
|
28
|
+
reconciler.updateContainer(
|
|
29
|
+
<>
|
|
30
|
+
{element}
|
|
31
|
+
<OverlayLayer />
|
|
32
|
+
</>,
|
|
33
|
+
container,
|
|
34
|
+
null,
|
|
35
|
+
null,
|
|
36
|
+
);
|
|
27
37
|
flushOps();
|
|
28
38
|
}
|
|
29
39
|
|