@gadxr/ui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/Avatar.svelte +136 -0
- package/dist/Avatar.svelte.d.ts +13 -0
- package/dist/Button.svelte +29 -0
- package/dist/Button.svelte.d.ts +4 -0
- package/dist/Button2D.svelte +77 -0
- package/dist/Button2D.svelte.d.ts +4 -0
- package/dist/Button3D.svelte +98 -0
- package/dist/Button3D.svelte.d.ts +4 -0
- package/dist/ButtonGroup.svelte +59 -0
- package/dist/ButtonGroup.svelte.d.ts +17 -0
- package/dist/Checkbox.svelte +209 -0
- package/dist/Checkbox.svelte.d.ts +14 -0
- package/dist/DimensionScope.svelte +7 -0
- package/dist/DimensionScope.svelte.d.ts +9 -0
- package/dist/Input.svelte +293 -0
- package/dist/Input.svelte.d.ts +23 -0
- package/dist/Key3D.svelte +67 -0
- package/dist/Key3D.svelte.d.ts +13 -0
- package/dist/Keyboard.svelte +167 -0
- package/dist/Keyboard.svelte.d.ts +9 -0
- package/dist/Menu.svelte +193 -0
- package/dist/Menu.svelte.d.ts +23 -0
- package/dist/Modal.svelte +103 -0
- package/dist/Modal.svelte.d.ts +16 -0
- package/dist/Panel.svelte +132 -0
- package/dist/Panel.svelte.d.ts +19 -0
- package/dist/Pressable3D.svelte +82 -0
- package/dist/Pressable3D.svelte.d.ts +28 -0
- package/dist/ProgressBar.svelte +153 -0
- package/dist/ProgressBar.svelte.d.ts +16 -0
- package/dist/RadialMenu.svelte +322 -0
- package/dist/RadialMenu.svelte.d.ts +28 -0
- package/dist/Rule3D.svelte +17 -0
- package/dist/Rule3D.svelte.d.ts +9 -0
- package/dist/Scene3D.svelte +24 -0
- package/dist/Scene3D.svelte.d.ts +11 -0
- package/dist/Slider.svelte +209 -0
- package/dist/Slider.svelte.d.ts +17 -0
- package/dist/Space3D.svelte +6 -0
- package/dist/Space3D.svelte.d.ts +7 -0
- package/dist/Spinner.svelte +64 -0
- package/dist/Spinner.svelte.d.ts +10 -0
- package/dist/Stack.svelte +41 -0
- package/dist/Stack.svelte.d.ts +15 -0
- package/dist/StackScope.svelte +7 -0
- package/dist/StackScope.svelte.d.ts +9 -0
- package/dist/Surface.svelte +152 -0
- package/dist/Surface.svelte.d.ts +30 -0
- package/dist/Tab.svelte +40 -0
- package/dist/Tab.svelte.d.ts +10 -0
- package/dist/Tabs.svelte +145 -0
- package/dist/Tabs.svelte.d.ts +13 -0
- package/dist/Tag.svelte +71 -0
- package/dist/Tag.svelte.d.ts +12 -0
- package/dist/Text.svelte +75 -0
- package/dist/Text.svelte.d.ts +4 -0
- package/dist/TextLine3D.svelte +84 -0
- package/dist/TextLine3D.svelte.d.ts +19 -0
- package/dist/ThemeProvider.svelte +7 -0
- package/dist/ThemeProvider.svelte.d.ts +9 -0
- package/dist/Toaster.svelte +104 -0
- package/dist/Toaster.svelte.d.ts +11 -0
- package/dist/Tooltip.svelte +174 -0
- package/dist/Tooltip.svelte.d.ts +14 -0
- package/dist/aframe/frame.d.ts +1 -0
- package/dist/aframe/frame.js +37 -0
- package/dist/aframe/index.d.ts +1 -0
- package/dist/aframe/index.js +19 -0
- package/dist/aframe/measure.d.ts +3 -0
- package/dist/aframe/measure.js +42 -0
- package/dist/aframe/placement.d.ts +88 -0
- package/dist/aframe/placement.js +163 -0
- package/dist/aframe/rounded.d.ts +61 -0
- package/dist/aframe/rounded.js +121 -0
- package/dist/aframe/scene.d.ts +9 -0
- package/dist/aframe/scene.js +101 -0
- package/dist/assets/Geist-OFL.txt +93 -0
- package/dist/assets/Geist-Regular.ttf +0 -0
- package/dist/button-style.d.ts +22 -0
- package/dist/button-style.js +34 -0
- package/dist/component-style.d.ts +79 -0
- package/dist/component-style.js +129 -0
- package/dist/context.d.ts +16 -0
- package/dist/context.js +26 -0
- package/dist/fonts.css +7 -0
- package/dist/fonts.d.ts +2 -0
- package/dist/fonts.js +7 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +40 -0
- package/dist/keyboard-layout.d.ts +28 -0
- package/dist/keyboard-layout.js +77 -0
- package/dist/layout-math.d.ts +20 -0
- package/dist/layout-math.js +22 -0
- package/dist/layout.svelte.d.ts +17 -0
- package/dist/layout.svelte.js +58 -0
- package/dist/radial-math.d.ts +33 -0
- package/dist/radial-math.js +90 -0
- package/dist/tabs-context.d.ts +13 -0
- package/dist/tabs-context.js +6 -0
- package/dist/text-edit.d.ts +46 -0
- package/dist/text-edit.js +133 -0
- package/dist/text-focus.svelte.d.ts +24 -0
- package/dist/text-focus.svelte.js +102 -0
- package/dist/theme.d.ts +335 -0
- package/dist/theme.js +312 -0
- package/dist/toast.svelte.d.ts +24 -0
- package/dist/toast.svelte.js +30 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/dist/xr.svelte.d.ts +9 -0
- package/dist/xr.svelte.js +24 -0
- package/package.json +44 -3
- package/README.md +0 -3
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script lang="ts">// Base UI-style ProgressBar: how far along something is (a download, a vote, loading).
|
|
2
|
+
// `infinite` when there's no known amount: a segment sweeps back and forth instead.
|
|
3
|
+
// Optional header row: `label` on the left, the percentage on the right (`showValue`).
|
|
4
|
+
import { ensure3d } from "./aframe";
|
|
5
|
+
import { sliderFraction } from "./component-style";
|
|
6
|
+
import { getDimension, getTheme } from "./context";
|
|
7
|
+
import { setStack, useSlot } from "./layout.svelte";
|
|
8
|
+
import Text from "./Text.svelte";
|
|
9
|
+
const theme = getTheme();
|
|
10
|
+
let { value = 0, max = 100, infinite = false, size = "medium", label = "", showValue = false, color, width = theme().progressBar.width, dimension, style: userStyle = "", ...rest } = $props();
|
|
11
|
+
const t = $derived(theme());
|
|
12
|
+
const tk = $derived(t.progressBar);
|
|
13
|
+
const fill = $derived(color ?? t.colors.progressBar.fill);
|
|
14
|
+
const fraction = $derived(sliderFraction(value, 0, max));
|
|
15
|
+
const percent = $derived(`${Math.round(fraction * 100)}%`);
|
|
16
|
+
const font = $derived(t.typography[tk.typography]);
|
|
17
|
+
const hasHeader = $derived(!!label || showValue && !infinite);
|
|
18
|
+
const contextDimension = getDimension();
|
|
19
|
+
// svelte-ignore state_referenced_locally
|
|
20
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
21
|
+
// ---- 3D ----
|
|
22
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
23
|
+
if (is3d) ensure3d();
|
|
24
|
+
const W = $derived(m(width));
|
|
25
|
+
const barH = $derived(m(tk.sizes[size]));
|
|
26
|
+
const headerH = $derived(hasHeader ? m(font.lineHeight) * t.space3d.textScale : 0);
|
|
27
|
+
const headerGap = $derived(hasHeader ? m(tk.gap) : 0);
|
|
28
|
+
const box = $derived({
|
|
29
|
+
width: W,
|
|
30
|
+
height: headerH + headerGap + barH
|
|
31
|
+
});
|
|
32
|
+
const slot = is3d ? useSlot(() => box) : () => ({
|
|
33
|
+
x: 0,
|
|
34
|
+
y: 0
|
|
35
|
+
});
|
|
36
|
+
// The header Texts inside are placed by hand, not by a parent Panel's stack.
|
|
37
|
+
setStack(undefined);
|
|
38
|
+
const barY = $derived(-box.height / 2 + barH / 2);
|
|
39
|
+
const headerY = $derived(box.height / 2 - headerH / 2);
|
|
40
|
+
const depth = .01;
|
|
41
|
+
const segment = $derived(W * tk.segment);
|
|
42
|
+
const sweep = $derived(`property: position; from: ${-W / 2} ${-barH / 2} 0.001; to: ${W / 2 - segment} ${-barH / 2} 0.001; dur: ${tk.duration}; dir: alternate; loop: true; easing: easeInOutSine`);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
{#if is3d}
|
|
46
|
+
<a-entity {...rest}>
|
|
47
|
+
<a-entity position="{slot().x} {slot().y} 0">
|
|
48
|
+
{#if hasHeader}
|
|
49
|
+
<a-entity position="0 {headerY} 0">
|
|
50
|
+
{#if label}
|
|
51
|
+
<Text variant={tk.typography} text={label} align="left" position="{-W / 2} 0 0" />
|
|
52
|
+
{/if}
|
|
53
|
+
{#if showValue && !infinite}
|
|
54
|
+
<Text variant={tk.typography} text={percent} align="right" color={t.colors.contentSecondary} position="{W / 2} 0 0" />
|
|
55
|
+
{/if}
|
|
56
|
+
</a-entity>
|
|
57
|
+
{/if}
|
|
58
|
+
<a-entity position="0 {barY} 0">
|
|
59
|
+
<a-entity
|
|
60
|
+
rounded="width: {W}; height: {barH}; depth: {depth}; radius: {barH / 2}; color: {t.colors.progressBar.track}"
|
|
61
|
+
position="{-W / 2} {-barH / 2} 0"
|
|
62
|
+
></a-entity>
|
|
63
|
+
{#if infinite}
|
|
64
|
+
<!-- Keyed on the sweep so a size change restarts it from the new ends. -->
|
|
65
|
+
{#key sweep}
|
|
66
|
+
<a-entity position="{-W / 2} {-barH / 2} 0.001" animation__sweep={sweep}>
|
|
67
|
+
<a-entity rounded="width: {segment}; height: {barH}; depth: {depth}; radius: {barH / 2}; color: {fill}"></a-entity>
|
|
68
|
+
</a-entity>
|
|
69
|
+
{/key}
|
|
70
|
+
{:else}
|
|
71
|
+
<a-entity
|
|
72
|
+
rounded="width: {Math.max(W * fraction, 0.0001)}; height: {barH}; depth: {depth}; radius: {barH / 2}; color: {fill}; enabled: {fraction > 0}"
|
|
73
|
+
position="{-W / 2} {-barH / 2} 0.001"
|
|
74
|
+
></a-entity>
|
|
75
|
+
{/if}
|
|
76
|
+
</a-entity>
|
|
77
|
+
</a-entity>
|
|
78
|
+
</a-entity>
|
|
79
|
+
{:else}
|
|
80
|
+
<div
|
|
81
|
+
class="ui-progress"
|
|
82
|
+
role="progressbar"
|
|
83
|
+
aria-label={label || undefined}
|
|
84
|
+
aria-valuemin={0}
|
|
85
|
+
aria-valuemax={max}
|
|
86
|
+
aria-valuenow={infinite ? undefined : Math.min(max, Math.max(0, value))}
|
|
87
|
+
style="--ui-width: {width}px; --ui-bar: {tk.sizes[size]}px; --ui-track: {t.colors.progressBar.track}; --ui-fill: {fill}; --ui-pct: {fraction * 100}%; --ui-segment: {tk.segment * 100}%; --ui-duration: {tk.duration}ms; --ui-gap: {tk.gap}px; --ui-font: {font.fontWeight} {font.fontSize}px/{font.lineHeight}px {t.typography.fontFamily}; --ui-label: {t.colors.contentPrimary}; --ui-value: {t.colors.contentSecondary}; {userStyle}"
|
|
88
|
+
{...rest}
|
|
89
|
+
>
|
|
90
|
+
{#if hasHeader}
|
|
91
|
+
<span class="header">
|
|
92
|
+
<span>{label}</span>
|
|
93
|
+
{#if showValue && !infinite}<span class="value">{percent}</span>{/if}
|
|
94
|
+
</span>
|
|
95
|
+
{/if}
|
|
96
|
+
<span class="track"><span class="fill" class:infinite></span></span>
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
<style>
|
|
101
|
+
.ui-progress {
|
|
102
|
+
display: inline-flex;
|
|
103
|
+
flex-direction: column;
|
|
104
|
+
gap: var(--ui-gap);
|
|
105
|
+
width: var(--ui-width);
|
|
106
|
+
font: var(--ui-font);
|
|
107
|
+
color: var(--ui-label);
|
|
108
|
+
}
|
|
109
|
+
.header {
|
|
110
|
+
display: flex;
|
|
111
|
+
justify-content: space-between;
|
|
112
|
+
}
|
|
113
|
+
.value {
|
|
114
|
+
color: var(--ui-value);
|
|
115
|
+
font-variant-numeric: tabular-nums;
|
|
116
|
+
}
|
|
117
|
+
.track {
|
|
118
|
+
position: relative;
|
|
119
|
+
display: block;
|
|
120
|
+
height: var(--ui-bar);
|
|
121
|
+
border-radius: var(--ui-bar);
|
|
122
|
+
background: var(--ui-track);
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
}
|
|
125
|
+
.fill {
|
|
126
|
+
position: absolute;
|
|
127
|
+
inset: 0 auto 0 0;
|
|
128
|
+
width: var(--ui-pct);
|
|
129
|
+
border-radius: inherit;
|
|
130
|
+
background: var(--ui-fill);
|
|
131
|
+
transition: width 0.2s ease-out;
|
|
132
|
+
}
|
|
133
|
+
.fill.infinite {
|
|
134
|
+
width: var(--ui-segment);
|
|
135
|
+
animation: ui-progress-sweep var(--ui-duration) ease-in-out infinite alternate;
|
|
136
|
+
}
|
|
137
|
+
@keyframes ui-progress-sweep {
|
|
138
|
+
from {
|
|
139
|
+
left: 0;
|
|
140
|
+
}
|
|
141
|
+
to {
|
|
142
|
+
left: calc(100% - var(--ui-segment));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
@media (prefers-reduced-motion: reduce) {
|
|
146
|
+
.fill {
|
|
147
|
+
transition: none;
|
|
148
|
+
}
|
|
149
|
+
.fill.infinite {
|
|
150
|
+
animation-duration: calc(var(--ui-duration) * 3);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Dimension, ProgressBarSize } from './theme';
|
|
2
|
+
type Props = {
|
|
3
|
+
value?: number;
|
|
4
|
+
max?: number;
|
|
5
|
+
infinite?: boolean;
|
|
6
|
+
size?: ProgressBarSize;
|
|
7
|
+
label?: string;
|
|
8
|
+
showValue?: boolean;
|
|
9
|
+
color?: string;
|
|
10
|
+
width?: number;
|
|
11
|
+
dimension?: Dimension;
|
|
12
|
+
[attribute: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
declare const ProgressBar: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type ProgressBar = ReturnType<typeof ProgressBar>;
|
|
16
|
+
export default ProgressBar;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
<script lang="ts">// A radial (pie) menu: hold to open, aim at a wedge, release to pick.
|
|
2
|
+
// <RadialMenu items={emotes} onselect={(item, i) => …} hotkey="e" /> (desktop)
|
|
3
|
+
// <RadialMenu items={emotes} onselect={…} controller="#leftHand" hand="left" dimension="3d" /> (VR)
|
|
4
|
+
// Same `items` as Menu (dividers are skipped). Ways to drive it:
|
|
5
|
+
// VR: `controller` — `openOn` (trigger down) opens, the thumbstick aims, `closeOn` picks.
|
|
6
|
+
// Desktop: hold `hotkey`; the ring opens at the pointer, moving the pointer aims,
|
|
7
|
+
// releasing picks. Clicking or hovering a wedge (mouse or 3D cursor/laser) works too.
|
|
8
|
+
// Keyboard while open: arrows move around the ring, Enter picks, Escape cancels.
|
|
9
|
+
// Or control it yourself with `bind:open`.
|
|
10
|
+
import { untrack } from "svelte";
|
|
11
|
+
import { ensure3d } from "./aframe";
|
|
12
|
+
import { getDimension, getTheme } from "./context";
|
|
13
|
+
import { font3d } from "./fonts";
|
|
14
|
+
import { ON_CONTROLLER, polar, RadialInput, sectorAngle, sectorPath, wedgeCenter } from "./radial-math";
|
|
15
|
+
import StackScope from "./StackScope.svelte";
|
|
16
|
+
const theme = getTheme();
|
|
17
|
+
let { items, onselect, open = $bindable(false), disabled = false, startAngle, angleOffset, deadzone = .5, controller, hand = "right", openOn = "triggerdown", closeOn = "triggerup", hotkey, radius, innerRadius, labelRotation, dimension, ...rest } = $props();
|
|
18
|
+
// svelte-ignore state_referenced_locally
|
|
19
|
+
const onController = dimension !== "2d" && !!controller;
|
|
20
|
+
// svelte-ignore state_referenced_locally
|
|
21
|
+
const defaults = ON_CONTROLLER[hand];
|
|
22
|
+
const fallback = (key) => onController ? defaults[key] : undefined;
|
|
23
|
+
const ringRadius = $derived(radius ?? fallback("radius") ?? theme().radial.radius);
|
|
24
|
+
const stickOffset = $derived(angleOffset ?? fallback("angleOffset") ?? 0);
|
|
25
|
+
const labelTurn = $derived(labelRotation ?? fallback("labelRotation") ?? 0);
|
|
26
|
+
const placement = $derived({
|
|
27
|
+
position: rest.position ?? fallback("position"),
|
|
28
|
+
rotation: rest.rotation ?? fallback("rotation")
|
|
29
|
+
});
|
|
30
|
+
const t = $derived(theme());
|
|
31
|
+
const c = $derived(t.colors.radial);
|
|
32
|
+
const wedges = $derived(items.filter((item) => !("divider" in item)));
|
|
33
|
+
const layout = $derived({
|
|
34
|
+
count: wedges.length,
|
|
35
|
+
startAngle: startAngle ?? fallback("startAngle") ?? 90 - sectorAngle(Math.max(1, wedges.length)) / 2
|
|
36
|
+
});
|
|
37
|
+
const inner = $derived(innerRadius ?? fallback("innerRadius") ?? ringRadius * (t.radial.innerRadius / t.radial.radius));
|
|
38
|
+
const font = $derived(t.typography[t.radial.typography]);
|
|
39
|
+
const contextDimension = getDimension();
|
|
40
|
+
// svelte-ignore state_referenced_locally
|
|
41
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
42
|
+
if (is3d) ensure3d();
|
|
43
|
+
// ---- input (shared) ----
|
|
44
|
+
const input = new RadialInput({
|
|
45
|
+
count: 0,
|
|
46
|
+
startAngle: 0
|
|
47
|
+
}, .5);
|
|
48
|
+
let selected = $state(-1);
|
|
49
|
+
$effect(() => {
|
|
50
|
+
input.layout = layout;
|
|
51
|
+
input.disabled = (i) => !!wedges[i]?.disabled;
|
|
52
|
+
});
|
|
53
|
+
let pointer = {
|
|
54
|
+
x: innerWidth / 2,
|
|
55
|
+
y: innerHeight / 2
|
|
56
|
+
};
|
|
57
|
+
let center = $state({
|
|
58
|
+
x: 0,
|
|
59
|
+
y: 0
|
|
60
|
+
});
|
|
61
|
+
function begin() {
|
|
62
|
+
if (disabled || input.open) return;
|
|
63
|
+
input.start();
|
|
64
|
+
selected = -1;
|
|
65
|
+
center = clampToView(pointer);
|
|
66
|
+
open = true;
|
|
67
|
+
}
|
|
68
|
+
function sync() {
|
|
69
|
+
selected = input.selected;
|
|
70
|
+
}
|
|
71
|
+
function finish() {
|
|
72
|
+
const index = input.release();
|
|
73
|
+
selected = -1;
|
|
74
|
+
open = false;
|
|
75
|
+
if (index >= 0) onselect?.(wedges[index], index);
|
|
76
|
+
}
|
|
77
|
+
function cancel() {
|
|
78
|
+
input.cancel();
|
|
79
|
+
selected = -1;
|
|
80
|
+
open = false;
|
|
81
|
+
}
|
|
82
|
+
// `bind:open` from outside opens/closes it too (closing that way picks nothing).
|
|
83
|
+
$effect(() => {
|
|
84
|
+
const wanted = open;
|
|
85
|
+
untrack(() => {
|
|
86
|
+
if (wanted && !input.open) begin();
|
|
87
|
+
if (!wanted && input.open) cancel();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
function clampToView(p) {
|
|
91
|
+
const r = ringRadius + 8;
|
|
92
|
+
return {
|
|
93
|
+
x: Math.min(Math.max(p.x, r), innerWidth - r),
|
|
94
|
+
y: Math.min(Math.max(p.y, r), innerHeight - r)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// ---- desktop: hotkey, pointer, keyboard ----
|
|
98
|
+
const typing = (e) => e.target?.closest?.("input, textarea, [contenteditable]");
|
|
99
|
+
function onkeydown(event) {
|
|
100
|
+
if (hotkey && event.key.toLowerCase() === hotkey.toLowerCase() && !event.repeat && !typing(event)) begin();
|
|
101
|
+
if (!input.open) return;
|
|
102
|
+
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? -1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? 1 : 0;
|
|
103
|
+
if (step) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
// Left / Up go counterclockwise, Right / Down clockwise, skipping disabled wedges.
|
|
106
|
+
const n = wedges.length;
|
|
107
|
+
let i = selected;
|
|
108
|
+
for (let k = 0; k < n; k++) {
|
|
109
|
+
i = i < 0 ? step > 0 ? 0 : n - 1 : (i + step + n) % n;
|
|
110
|
+
if (!wedges[i].disabled) {
|
|
111
|
+
input.point(i);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
sync();
|
|
116
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
finish();
|
|
119
|
+
} else if (event.key === "Escape") {
|
|
120
|
+
cancel();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function onkeyup(event) {
|
|
124
|
+
if (hotkey && input.open && event.key.toLowerCase() === hotkey.toLowerCase()) finish();
|
|
125
|
+
}
|
|
126
|
+
function onpointermove(event) {
|
|
127
|
+
pointer = {
|
|
128
|
+
x: event.clientX,
|
|
129
|
+
y: event.clientY
|
|
130
|
+
};
|
|
131
|
+
if (is3d || !input.open) return;
|
|
132
|
+
// Pointer offset from the centre, scaled so the deadzone matches the stick's 0.5.
|
|
133
|
+
const scale = 2 * t.radial.deadzone;
|
|
134
|
+
input.aim((event.clientX - center.x) / scale, -(event.clientY - center.y) / scale);
|
|
135
|
+
sync();
|
|
136
|
+
}
|
|
137
|
+
// ---- VR: controller trigger + thumbstick ----
|
|
138
|
+
let controllerEl = null;
|
|
139
|
+
// On a controller, the stick aims. Ignore cursors that would fight it: a laser on the same
|
|
140
|
+
// controller (it passes through its own ring), and the scene's mouse cursor during an XR
|
|
141
|
+
// session (A-Frame keeps its ray active in VR, so it can rest on the ring).
|
|
142
|
+
const fromOwnController = (event) => {
|
|
143
|
+
if (!controllerEl) return false;
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
|
+
const cursorEl = event.detail?.cursorEl;
|
|
146
|
+
return cursorEl === controllerEl || cursorEl?.isScene && !!cursorEl.xrSession;
|
|
147
|
+
};
|
|
148
|
+
$effect(() => {
|
|
149
|
+
if (!is3d || !controller) return;
|
|
150
|
+
const el = document.querySelector(controller);
|
|
151
|
+
controllerEl = el;
|
|
152
|
+
if (!el) return;
|
|
153
|
+
const onOpen = () => begin();
|
|
154
|
+
const onClose = () => input.open && finish();
|
|
155
|
+
const onStick = (event) => {
|
|
156
|
+
const { x, y } = event.detail;
|
|
157
|
+
// Stick forward is -y.
|
|
158
|
+
input.aim(x, -y, stickOffset);
|
|
159
|
+
sync();
|
|
160
|
+
};
|
|
161
|
+
el.addEventListener(openOn, onOpen);
|
|
162
|
+
el.addEventListener(closeOn, onClose);
|
|
163
|
+
el.addEventListener("thumbstickmoved", onStick);
|
|
164
|
+
return () => {
|
|
165
|
+
el.removeEventListener(openOn, onOpen);
|
|
166
|
+
el.removeEventListener(closeOn, onClose);
|
|
167
|
+
el.removeEventListener("thumbstickmoved", onStick);
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
// ---- 3D drawing ----
|
|
171
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
172
|
+
const R = $derived(m(ringRadius));
|
|
173
|
+
const r = $derived(m(inner));
|
|
174
|
+
const labelSize3d = $derived(Math.min(m(font.fontSize) * t.space3d.textScale, R * .5));
|
|
175
|
+
const labelSize2d = $derived(Math.min(font.fontSize, ringRadius * .5));
|
|
176
|
+
const labelAt = (i) => polar((ringRadius + inner) / 2, wedgeCenter(i, layout));
|
|
177
|
+
const motion = $derived(`dur: ${t.animation.timing100}; easing: ${t.animation.easeOut3d}`);
|
|
178
|
+
const colorOf = (i) => i === selected ? c.active : c.fill;
|
|
179
|
+
const textOf = (i) => wedges[i].disabled ? c.disabledText : c.text;
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<svelte:window {onkeydown} {onkeyup} {onpointermove} />
|
|
183
|
+
|
|
184
|
+
{#if is3d}
|
|
185
|
+
<StackScope>
|
|
186
|
+
<a-entity {...rest} position={placement.position} rotation={placement.rotation} visible={open}>
|
|
187
|
+
<!-- Backing ring, for depth. -->
|
|
188
|
+
<a-entity
|
|
189
|
+
geometry="primitive: ring; radiusInner: {r * 0.85}; radiusOuter: {R * 1.04}; segmentsTheta: 48"
|
|
190
|
+
material="color: {c.ring}; opacity: 0.85; transparent: true; side: double"
|
|
191
|
+
position="0 0 -{m(4)}"
|
|
192
|
+
></a-entity>
|
|
193
|
+
{#each wedges as item, i}
|
|
194
|
+
{@const raised = i === selected}
|
|
195
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_mouse_events_have_key_events -->
|
|
196
|
+
<a-entity
|
|
197
|
+
class={open && !item.disabled ? 'raycastable' : ''}
|
|
198
|
+
geometry="primitive: ring; radiusInner: {r}; radiusOuter: {R}; thetaStart: {layout.startAngle + i * sectorAngle(layout.count) + t.radial.gap / 2}; thetaLength: {sectorAngle(layout.count) - t.radial.gap}; segmentsTheta: 16"
|
|
199
|
+
material="color: {colorOf(i)}; side: double; roughness: 0.6"
|
|
200
|
+
animation__pop="property: object3D.position.z; to: {raised ? t.space3d.raise : 0}; {motion}"
|
|
201
|
+
animation__grow="property: scale; to: {raised ? 1.06 : 1} {raised ? 1.06 : 1} 1; {motion}"
|
|
202
|
+
onmouseenter={(event: Event) => {
|
|
203
|
+
if (fromOwnController(event)) return;
|
|
204
|
+
input.point(i);
|
|
205
|
+
sync();
|
|
206
|
+
}}
|
|
207
|
+
onclick={(event: Event) => {
|
|
208
|
+
if (fromOwnController(event)) return;
|
|
209
|
+
input.point(i);
|
|
210
|
+
if (input.selected === i) finish();
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<a-entity
|
|
214
|
+
troika-text="value: {item.label.replace(/;/g, ',')}; fontSize: {labelSize3d}; color: {textOf(i)}; font: {font3d(t)}; anchor: center; baseline: center"
|
|
215
|
+
troika-text-material="shader: standard"
|
|
216
|
+
position="{m(labelAt(i)[0])} {m(labelAt(i)[1])} 0.003"
|
|
217
|
+
rotation="0 0 {labelTurn}"
|
|
218
|
+
></a-entity>
|
|
219
|
+
</a-entity>
|
|
220
|
+
{/each}
|
|
221
|
+
</a-entity>
|
|
222
|
+
</StackScope>
|
|
223
|
+
{:else if open}
|
|
224
|
+
<div
|
|
225
|
+
class="ui-radial"
|
|
226
|
+
role="menu"
|
|
227
|
+
aria-label="Radial menu"
|
|
228
|
+
style="left: {center.x}px; top: {center.y}px; --ui-size: {ringRadius * 2}px; --ui-fill: {c.fill}; --ui-active: {c.active}; --ui-text: {c.text}; --ui-disabled: {c.disabledText}; --ui-ring: {c.ring}; --ui-font: {font.fontWeight} {labelSize2d}px/1 {t.typography.fontFamily};"
|
|
229
|
+
>
|
|
230
|
+
<svg viewBox="{-ringRadius - 6} {-ringRadius - 6} {ringRadius * 2 + 12} {ringRadius * 2 + 12}" aria-hidden="true">
|
|
231
|
+
<circle r={ringRadius + 4} class="ring" />
|
|
232
|
+
</svg>
|
|
233
|
+
<svg viewBox="{-ringRadius - 6} {-ringRadius - 6} {ringRadius * 2 + 12} {ringRadius * 2 + 12}">
|
|
234
|
+
{#each wedges as item, i}
|
|
235
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
236
|
+
<path
|
|
237
|
+
d={sectorPath(i, layout, inner, ringRadius, t.radial.gap)}
|
|
238
|
+
role="menuitem"
|
|
239
|
+
tabindex="-1"
|
|
240
|
+
aria-label={item.label}
|
|
241
|
+
aria-disabled={item.disabled}
|
|
242
|
+
class:active={i === selected}
|
|
243
|
+
class:disabled={item.disabled}
|
|
244
|
+
onpointerenter={() => {
|
|
245
|
+
input.point(i);
|
|
246
|
+
sync();
|
|
247
|
+
}}
|
|
248
|
+
onclick={() => {
|
|
249
|
+
input.point(i);
|
|
250
|
+
if (input.selected === i) finish();
|
|
251
|
+
}}
|
|
252
|
+
/>
|
|
253
|
+
{/each}
|
|
254
|
+
</svg>
|
|
255
|
+
{#each wedges as item, i}
|
|
256
|
+
<span
|
|
257
|
+
class="label"
|
|
258
|
+
class:disabled={item.disabled}
|
|
259
|
+
style="left: calc(50% + {labelAt(i)[0]}px); top: calc(50% - {labelAt(i)[1]}px);">{item.label}</span
|
|
260
|
+
>
|
|
261
|
+
{/each}
|
|
262
|
+
</div>
|
|
263
|
+
{/if}
|
|
264
|
+
|
|
265
|
+
<style>
|
|
266
|
+
.ui-radial {
|
|
267
|
+
position: fixed;
|
|
268
|
+
z-index: 950;
|
|
269
|
+
width: calc(var(--ui-size) + 12px);
|
|
270
|
+
height: calc(var(--ui-size) + 12px);
|
|
271
|
+
transform: translate(-50%, -50%);
|
|
272
|
+
animation: ui-radial-in 0.15s cubic-bezier(0.2, 0.8, 0.4, 1);
|
|
273
|
+
user-select: none;
|
|
274
|
+
}
|
|
275
|
+
svg {
|
|
276
|
+
position: absolute;
|
|
277
|
+
inset: 0;
|
|
278
|
+
width: 100%;
|
|
279
|
+
height: 100%;
|
|
280
|
+
overflow: visible;
|
|
281
|
+
}
|
|
282
|
+
.ring {
|
|
283
|
+
fill: none;
|
|
284
|
+
stroke: var(--ui-ring);
|
|
285
|
+
stroke-width: 8;
|
|
286
|
+
opacity: 0.6;
|
|
287
|
+
}
|
|
288
|
+
path {
|
|
289
|
+
fill: var(--ui-fill);
|
|
290
|
+
stroke: none;
|
|
291
|
+
cursor: pointer;
|
|
292
|
+
transition: fill 0.1s, transform 0.1s;
|
|
293
|
+
transform-origin: 0 0;
|
|
294
|
+
}
|
|
295
|
+
path.active {
|
|
296
|
+
fill: var(--ui-active);
|
|
297
|
+
transform: scale(1.05);
|
|
298
|
+
}
|
|
299
|
+
path.disabled {
|
|
300
|
+
cursor: not-allowed;
|
|
301
|
+
fill-opacity: 0.5;
|
|
302
|
+
}
|
|
303
|
+
.label {
|
|
304
|
+
position: absolute;
|
|
305
|
+
transform: translate(-50%, -50%);
|
|
306
|
+
font: var(--ui-font);
|
|
307
|
+
color: var(--ui-text);
|
|
308
|
+
pointer-events: none;
|
|
309
|
+
white-space: nowrap;
|
|
310
|
+
}
|
|
311
|
+
.label.disabled {
|
|
312
|
+
color: var(--ui-disabled);
|
|
313
|
+
/* Emoji ignore color. */
|
|
314
|
+
opacity: 0.35;
|
|
315
|
+
}
|
|
316
|
+
@keyframes ui-radial-in {
|
|
317
|
+
from {
|
|
318
|
+
opacity: 0;
|
|
319
|
+
transform: translate(-50%, -50%) scale(0.85);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MenuItem } from './Menu.svelte';
|
|
2
|
+
import { type Hand } from './radial-math';
|
|
3
|
+
import type { Dimension } from './theme';
|
|
4
|
+
type Item = Exclude<MenuItem, {
|
|
5
|
+
divider: true;
|
|
6
|
+
}>;
|
|
7
|
+
type Props = {
|
|
8
|
+
items: MenuItem[];
|
|
9
|
+
onselect?: (item: Item, index: number) => void;
|
|
10
|
+
open?: boolean;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
startAngle?: number;
|
|
13
|
+
angleOffset?: number;
|
|
14
|
+
deadzone?: number;
|
|
15
|
+
controller?: string;
|
|
16
|
+
hand?: Hand;
|
|
17
|
+
openOn?: string;
|
|
18
|
+
closeOn?: string;
|
|
19
|
+
hotkey?: string;
|
|
20
|
+
radius?: number;
|
|
21
|
+
innerRadius?: number;
|
|
22
|
+
labelRotation?: number;
|
|
23
|
+
dimension?: Dimension;
|
|
24
|
+
[attribute: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
declare const RadialMenu: import("svelte").Component<Props, {}, "open">;
|
|
27
|
+
type RadialMenu = ReturnType<typeof RadialMenu>;
|
|
28
|
+
export default RadialMenu;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">// Internal: a thin horizontal line in a 3D stack (menu dividers).
|
|
2
|
+
import { getTheme } from "./context";
|
|
3
|
+
import { useSlot } from "./layout.svelte";
|
|
4
|
+
// width in meters; thickness and margin (above and below) in px.
|
|
5
|
+
let { width, color, thickness = 1, margin = 6 } = $props();
|
|
6
|
+
const theme = getTheme();
|
|
7
|
+
const m = (px) => px * theme().space3d.metersPerPx;
|
|
8
|
+
const slot = useSlot(() => ({
|
|
9
|
+
width,
|
|
10
|
+
height: m(thickness + 2 * margin)
|
|
11
|
+
}));
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<a-entity
|
|
15
|
+
rounded="flat: true; width: {width}; height: {m(thickness)}; radius: 0; color: {color}"
|
|
16
|
+
position="{slot().x - width / 2} {slot().y - m(thickness) / 2} 0.001"
|
|
17
|
+
></a-entity>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script lang="ts">import { setDimension } from "./context";
|
|
2
|
+
import { xr } from "./xr.svelte";
|
|
3
|
+
import { MOUSE_CURSOR, RAYCASTABLE, loadTroika, setupScene } from "./aframe/scene";
|
|
4
|
+
let { children, cursor = MOUSE_CURSOR, raycaster = `objects: .${RAYCASTABLE}`, mouseCursorInVR = false, ...rest } = $props();
|
|
5
|
+
setDimension("3d");
|
|
6
|
+
const troika = loadTroika();
|
|
7
|
+
let ready = $state(!troika);
|
|
8
|
+
troika?.then(() => ready = true);
|
|
9
|
+
let scene = $state();
|
|
10
|
+
$effect(() => {
|
|
11
|
+
if (!scene) return;
|
|
12
|
+
xr.watch(scene);
|
|
13
|
+
return setupScene(scene, {
|
|
14
|
+
cursor,
|
|
15
|
+
mouseCursorInVR
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
{#if ready}
|
|
21
|
+
<a-scene bind:this={scene} {cursor} {raycaster} {...rest}>
|
|
22
|
+
{@render children?.()}
|
|
23
|
+
</a-scene>
|
|
24
|
+
{/if}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
children?: Snippet;
|
|
4
|
+
cursor?: string;
|
|
5
|
+
raycaster?: string;
|
|
6
|
+
mouseCursorInVR?: boolean;
|
|
7
|
+
[attribute: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
declare const Scene3D: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type Scene3D = ReturnType<typeof Scene3D>;
|
|
11
|
+
export default Scene3D;
|