@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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import './fonts.css';
|
|
2
|
+
export { default as ThemeProvider } from './ThemeProvider.svelte';
|
|
3
|
+
export { default as Scene3D } from './Scene3D.svelte';
|
|
4
|
+
export { default as Space3D } from './Space3D.svelte';
|
|
5
|
+
export { default as Text } from './Text.svelte';
|
|
6
|
+
export { default as Button } from './Button.svelte';
|
|
7
|
+
export { default as ButtonGroup } from './ButtonGroup.svelte';
|
|
8
|
+
export { default as Panel } from './Panel.svelte';
|
|
9
|
+
export { default as Stack } from './Stack.svelte';
|
|
10
|
+
export { default as Checkbox } from './Checkbox.svelte';
|
|
11
|
+
export { default as Slider } from './Slider.svelte';
|
|
12
|
+
export { default as Input } from './Input.svelte';
|
|
13
|
+
export { default as Keyboard } from './Keyboard.svelte';
|
|
14
|
+
export { default as Tag } from './Tag.svelte';
|
|
15
|
+
export { default as Spinner } from './Spinner.svelte';
|
|
16
|
+
export { default as ProgressBar } from './ProgressBar.svelte';
|
|
17
|
+
export { default as Avatar } from './Avatar.svelte';
|
|
18
|
+
export { default as Tooltip } from './Tooltip.svelte';
|
|
19
|
+
export { default as Tabs } from './Tabs.svelte';
|
|
20
|
+
export { default as Tab } from './Tab.svelte';
|
|
21
|
+
export { default as Menu, type MenuItem } from './Menu.svelte';
|
|
22
|
+
export { default as RadialMenu } from './RadialMenu.svelte';
|
|
23
|
+
export { default as Modal } from './Modal.svelte';
|
|
24
|
+
export { default as Toaster } from './Toaster.svelte';
|
|
25
|
+
export { default as Surface, type SurfacePlacement, type SurfaceDesktopPlacement, type ScreenRegion } from './Surface.svelte';
|
|
26
|
+
export { xr } from './xr.svelte';
|
|
27
|
+
export { toaster, DEFAULT_TOAST_DURATION, type Toast, type ToastOptions } from './toast.svelte';
|
|
28
|
+
export { defaultTheme, createTheme, toMeters } from './theme';
|
|
29
|
+
export type { Theme, ButtonKind, ButtonSize, ButtonShape, Dimension, TypographyVariant, TagKind, TagVariant, TagSize, SpinnerSize, ToastKind, ProgressBarSize, AvatarSize, AvatarStatus, TooltipPlacement } from './theme';
|
|
30
|
+
export { resolveButtonStyle } from './button-style';
|
|
31
|
+
export { resolveCheckboxStyle, resolveTagStyle, resolveInputStyle, resolveKeyStyle, sliderFraction, sliderValueAt, nextIndex, initials, avatarColor, tooltipOffset } from './component-style';
|
|
32
|
+
export { textFocus, type Editable } from './text-focus.svelte';
|
|
33
|
+
export { edit, keyToAction, type EditAction, type Splice, type TextState } from './text-edit';
|
|
34
|
+
export { KEYBOARD, type EnterKeyHint, type KeyboardPage, type ShiftState } from './keyboard-layout';
|
|
35
|
+
export { stackLayout } from './layout-math';
|
|
36
|
+
export { wedgeAt, wedgeCenter, RadialInput } from './radial-math';
|
|
37
|
+
export type { ButtonProps, TextProps } from './types';
|
|
38
|
+
export { ensure3d } from './aframe';
|
|
39
|
+
export { everyFrame } from './aframe/frame';
|
|
40
|
+
export { RAYCASTABLE, MOUSE_CURSOR, TROIKA_SCRIPT } from './aframe/scene';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// gadxr design system: Base UI-style components that render as HTML, or as
|
|
2
|
+
// A-Frame entities inside <Scene3D> (or <Space3D>).
|
|
3
|
+
import './fonts.css';
|
|
4
|
+
export { default as ThemeProvider } from './ThemeProvider.svelte';
|
|
5
|
+
export { default as Scene3D } from './Scene3D.svelte';
|
|
6
|
+
export { default as Space3D } from './Space3D.svelte';
|
|
7
|
+
export { default as Text } from './Text.svelte';
|
|
8
|
+
export { default as Button } from './Button.svelte';
|
|
9
|
+
export { default as ButtonGroup } from './ButtonGroup.svelte';
|
|
10
|
+
export { default as Panel } from './Panel.svelte';
|
|
11
|
+
export { default as Stack } from './Stack.svelte';
|
|
12
|
+
export { default as Checkbox } from './Checkbox.svelte';
|
|
13
|
+
export { default as Slider } from './Slider.svelte';
|
|
14
|
+
export { default as Input } from './Input.svelte';
|
|
15
|
+
export { default as Keyboard } from './Keyboard.svelte';
|
|
16
|
+
export { default as Tag } from './Tag.svelte';
|
|
17
|
+
export { default as Spinner } from './Spinner.svelte';
|
|
18
|
+
export { default as ProgressBar } from './ProgressBar.svelte';
|
|
19
|
+
export { default as Avatar } from './Avatar.svelte';
|
|
20
|
+
export { default as Tooltip } from './Tooltip.svelte';
|
|
21
|
+
export { default as Tabs } from './Tabs.svelte';
|
|
22
|
+
export { default as Tab } from './Tab.svelte';
|
|
23
|
+
export { default as Menu } from './Menu.svelte';
|
|
24
|
+
export { default as RadialMenu } from './RadialMenu.svelte';
|
|
25
|
+
export { default as Modal } from './Modal.svelte';
|
|
26
|
+
export { default as Toaster } from './Toaster.svelte';
|
|
27
|
+
export { default as Surface } from './Surface.svelte';
|
|
28
|
+
export { xr } from './xr.svelte';
|
|
29
|
+
export { toaster, DEFAULT_TOAST_DURATION } from './toast.svelte';
|
|
30
|
+
export { defaultTheme, createTheme, toMeters } from './theme';
|
|
31
|
+
export { resolveButtonStyle } from './button-style';
|
|
32
|
+
export { resolveCheckboxStyle, resolveTagStyle, resolveInputStyle, resolveKeyStyle, sliderFraction, sliderValueAt, nextIndex, initials, avatarColor, tooltipOffset } from './component-style';
|
|
33
|
+
export { textFocus } from './text-focus.svelte';
|
|
34
|
+
export { edit, keyToAction } from './text-edit';
|
|
35
|
+
export { KEYBOARD } from './keyboard-layout';
|
|
36
|
+
export { stackLayout } from './layout-math';
|
|
37
|
+
export { wedgeAt, wedgeCenter, RadialInput } from './radial-math';
|
|
38
|
+
export { ensure3d } from './aframe';
|
|
39
|
+
export { everyFrame } from './aframe/frame';
|
|
40
|
+
export { RAYCASTABLE, MOUSE_CURSOR, TROIKA_SCRIPT } from './aframe/scene';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type KeyboardPage = 'letters' | 'numbers' | 'symbols';
|
|
2
|
+
export type ShiftState = 'off' | 'once' | 'caps';
|
|
3
|
+
export type EnterKeyHint = 'enter' | 'done' | 'go' | 'next' | 'search' | 'send';
|
|
4
|
+
export type KeyDef = {
|
|
5
|
+
action: 'char';
|
|
6
|
+
char: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
} | {
|
|
9
|
+
action: 'shift' | 'backspace' | 'space' | 'enter' | 'done' | 'close';
|
|
10
|
+
width: number;
|
|
11
|
+
} | {
|
|
12
|
+
action: 'page';
|
|
13
|
+
page: KeyboardPage;
|
|
14
|
+
width: number;
|
|
15
|
+
} | {
|
|
16
|
+
action: 'spacer';
|
|
17
|
+
width: number;
|
|
18
|
+
};
|
|
19
|
+
export declare const ROW_UNITS = 11.5;
|
|
20
|
+
export declare const KEYBOARD: Record<KeyboardPage, KeyDef[][]>;
|
|
21
|
+
export declare const keyWidth: (key: KeyDef) => number;
|
|
22
|
+
export declare function keyLabel(key: KeyDef, shift?: ShiftState, enterKeyHint?: EnterKeyHint): string;
|
|
23
|
+
export declare function keyChar(key: {
|
|
24
|
+
char: string;
|
|
25
|
+
}, shift: ShiftState): string;
|
|
26
|
+
export declare function tapShift(shift: ShiftState, msSinceLastTap: number, doubleTapMs?: number): ShiftState;
|
|
27
|
+
export declare const afterChar: (shift: ShiftState) => ShiftState;
|
|
28
|
+
export declare const startPage: (inputmode: "text" | "numeric") => KeyboardPage;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The VR keyboard's keys and shift logic. Three pages: letters (QWERTY), numbers and
|
|
2
|
+
// symbols. Widths are in key units; every row adds up to ROW_UNITS, so rows line up.
|
|
3
|
+
// Labels stick to glyphs the bundled Geist has (no ⌫ / ✓), so troika needn't fetch
|
|
4
|
+
// a fallback font. Erasable TypeScript only (tested in Node).
|
|
5
|
+
export const ROW_UNITS = 11.5;
|
|
6
|
+
const chars = (s) => [...s].map((char) => ({ action: 'char', char }));
|
|
7
|
+
const backspace = { action: 'backspace', width: 1.5 };
|
|
8
|
+
const indent = { action: 'spacer', width: 0.5 };
|
|
9
|
+
const enter = { action: 'enter', width: 2 };
|
|
10
|
+
const bottom = (page) => [
|
|
11
|
+
{ action: 'page', page, width: 1.5 },
|
|
12
|
+
{ action: 'close', width: 1.25 },
|
|
13
|
+
{ action: 'space', width: 6.25 },
|
|
14
|
+
{ action: 'done', width: 2.5 },
|
|
15
|
+
];
|
|
16
|
+
export const KEYBOARD = {
|
|
17
|
+
letters: [
|
|
18
|
+
[...chars('qwertyuiop'), backspace],
|
|
19
|
+
[indent, ...chars('asdfghjkl'), enter],
|
|
20
|
+
[{ action: 'shift', width: 1.5 }, ...chars('zxcvbnm,.?')],
|
|
21
|
+
bottom('numbers'),
|
|
22
|
+
],
|
|
23
|
+
numbers: [
|
|
24
|
+
[...chars('1234567890'), backspace],
|
|
25
|
+
[indent, ...chars('-/:;()$&@'), enter],
|
|
26
|
+
[{ action: 'page', page: 'symbols', width: 1.5 }, ...chars(`.,?!'"_*%+`)],
|
|
27
|
+
bottom('letters'),
|
|
28
|
+
],
|
|
29
|
+
symbols: [
|
|
30
|
+
[...chars('[]{}#^=<>~'), backspace],
|
|
31
|
+
[indent, ...chars('\\|`€£¥•°…'), enter],
|
|
32
|
+
[{ action: 'page', page: 'numbers', width: 1.5 }, ...chars(`.,?!'"_*%+`)],
|
|
33
|
+
bottom('letters'),
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
export const keyWidth = (key) => ('width' in key && key.width ? key.width : 1);
|
|
37
|
+
const PAGE_LABEL = { letters: 'ABC', numbers: '123', symbols: '#+=' };
|
|
38
|
+
const ENTER_LABEL = { enter: '↵', done: 'Done', go: 'Go', next: 'Next', search: 'Search', send: 'Send' };
|
|
39
|
+
export function keyLabel(key, shift = 'off', enterKeyHint = 'enter') {
|
|
40
|
+
switch (key.action) {
|
|
41
|
+
case 'char':
|
|
42
|
+
return keyChar(key, shift);
|
|
43
|
+
case 'shift':
|
|
44
|
+
return shift === 'caps' ? 'CAPS' : '⇧';
|
|
45
|
+
case 'backspace':
|
|
46
|
+
return '←';
|
|
47
|
+
case 'space':
|
|
48
|
+
return 'space';
|
|
49
|
+
case 'enter':
|
|
50
|
+
return ENTER_LABEL[enterKeyHint];
|
|
51
|
+
case 'done':
|
|
52
|
+
return 'Done';
|
|
53
|
+
case 'close':
|
|
54
|
+
return '×';
|
|
55
|
+
case 'page':
|
|
56
|
+
return PAGE_LABEL[key.page];
|
|
57
|
+
case 'spacer':
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// What a character key types.
|
|
62
|
+
export function keyChar(key, shift) {
|
|
63
|
+
return shift === 'off' ? key.char : key.char.toUpperCase();
|
|
64
|
+
}
|
|
65
|
+
// Shift: a tap shifts the next letter; a second tap within `doubleTapMs` locks caps;
|
|
66
|
+
// any tap while locked (or a slow second tap) turns it off.
|
|
67
|
+
export function tapShift(shift, msSinceLastTap, doubleTapMs = 400) {
|
|
68
|
+
if (shift === 'off')
|
|
69
|
+
return 'once';
|
|
70
|
+
if (shift === 'once' && msSinceLastTap <= doubleTapMs)
|
|
71
|
+
return 'caps';
|
|
72
|
+
return 'off';
|
|
73
|
+
}
|
|
74
|
+
// After typing a character, a one-shot shift is used up; caps stays.
|
|
75
|
+
export const afterChar = (shift) => (shift === 'once' ? 'off' : shift);
|
|
76
|
+
// The page a field opens on.
|
|
77
|
+
export const startPage = (inputmode) => (inputmode === 'numeric' ? 'numbers' : 'letters');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Size = {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
};
|
|
5
|
+
export type Align = 'start' | 'center' | 'end';
|
|
6
|
+
export type Item = Size & {
|
|
7
|
+
align?: Align;
|
|
8
|
+
};
|
|
9
|
+
export type StackOptions = {
|
|
10
|
+
direction: 'row' | 'column';
|
|
11
|
+
gap: number;
|
|
12
|
+
align?: Align;
|
|
13
|
+
};
|
|
14
|
+
export type StackLayout = Size & {
|
|
15
|
+
offsets: {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
export declare function stackLayout(sizes: Item[], { direction, gap, align }: StackOptions): StackLayout;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 3D layout: stacks items in a row or column, centered on the stack's origin.
|
|
2
|
+
// Pure math, shared by every container (ButtonGroup, Panel). Units are meters.
|
|
3
|
+
// Erasable TypeScript only — tests import it with Node's type stripping.
|
|
4
|
+
export function stackLayout(sizes, { direction, gap, align = 'center' }) {
|
|
5
|
+
const row = direction === 'row';
|
|
6
|
+
const main = (s) => (row ? s.width : s.height);
|
|
7
|
+
const cross = (s) => (row ? s.height : s.width);
|
|
8
|
+
const totalMain = sizes.reduce((sum, s) => sum + main(s), 0) + gap * Math.max(0, sizes.length - 1);
|
|
9
|
+
const totalCross = sizes.reduce((max, s) => Math.max(max, cross(s)), 0);
|
|
10
|
+
let cursor = -totalMain / 2;
|
|
11
|
+
const offsets = sizes.map((s) => {
|
|
12
|
+
const along = cursor + main(s) / 2;
|
|
13
|
+
cursor += main(s) + gap;
|
|
14
|
+
const a = s.align ?? align;
|
|
15
|
+
const across = a === 'start' ? -totalCross / 2 + cross(s) / 2 : a === 'end' ? totalCross / 2 - cross(s) / 2 : 0;
|
|
16
|
+
// Rows run left to right; columns run top to bottom (+y is up).
|
|
17
|
+
return row ? { x: along, y: -across } : { x: across, y: -along };
|
|
18
|
+
});
|
|
19
|
+
return row
|
|
20
|
+
? { width: totalMain, height: totalCross, offsets }
|
|
21
|
+
: { width: totalCross, height: totalMain, offsets };
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Item, type Size, type StackOptions } from './layout-math';
|
|
2
|
+
export type Position = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
export type Stack = {
|
|
7
|
+
register(): number;
|
|
8
|
+
unregister(id: number): void;
|
|
9
|
+
setSize(id: number, size: Item): void;
|
|
10
|
+
offset(id: number): Position;
|
|
11
|
+
readonly size: Size;
|
|
12
|
+
};
|
|
13
|
+
export declare function createStack(options: () => StackOptions): Stack;
|
|
14
|
+
export declare function setStack(stack: Stack | undefined): void;
|
|
15
|
+
export declare function getStack(): Stack | undefined;
|
|
16
|
+
export declare function inStack(): boolean;
|
|
17
|
+
export declare function useSlot(size: () => Item): () => Position;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 3D layout context. A container (ButtonGroup, Panel) creates a stack; each 3D
|
|
2
|
+
// component inside calls useSlot() to report its size and get its position back.
|
|
3
|
+
// In 2D, CSS does the layout and none of this is used.
|
|
4
|
+
import { getContext, setContext } from 'svelte';
|
|
5
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
6
|
+
import { stackLayout } from './layout-math';
|
|
7
|
+
const STACK = Symbol('ui-stack');
|
|
8
|
+
export function createStack(options) {
|
|
9
|
+
// Ids increase in registration order, which is the layout order.
|
|
10
|
+
let nextId = 0;
|
|
11
|
+
const sizes = new SvelteMap();
|
|
12
|
+
// Iterating the map (not a plain list of ids) subscribes to items being added and
|
|
13
|
+
// removed, even when the layout is first read before any child has registered.
|
|
14
|
+
const layout = $derived.by(() => {
|
|
15
|
+
const ids = [...sizes.keys()].sort((a, b) => a - b);
|
|
16
|
+
const result = stackLayout(ids.map((id) => sizes.get(id)), options());
|
|
17
|
+
return { ...result, byId: new Map(ids.map((id, i) => [id, result.offsets[i]])) };
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
register: () => nextId++,
|
|
21
|
+
unregister(id) {
|
|
22
|
+
sizes.delete(id);
|
|
23
|
+
},
|
|
24
|
+
setSize(id, size) {
|
|
25
|
+
const prev = sizes.get(id);
|
|
26
|
+
if (!prev || prev.width !== size.width || prev.height !== size.height || prev.align !== size.align)
|
|
27
|
+
sizes.set(id, size);
|
|
28
|
+
},
|
|
29
|
+
offset: (id) => layout.byId.get(id) ?? { x: 0, y: 0 },
|
|
30
|
+
get size() {
|
|
31
|
+
return { width: layout.width, height: layout.height };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function setStack(stack) {
|
|
36
|
+
setContext(STACK, stack);
|
|
37
|
+
}
|
|
38
|
+
// For components that set up a different stack (or none) for part of their children.
|
|
39
|
+
export function getStack() {
|
|
40
|
+
return getContext(STACK);
|
|
41
|
+
}
|
|
42
|
+
// True when called inside a container that lays its children out.
|
|
43
|
+
export function inStack() {
|
|
44
|
+
return !!getContext(STACK);
|
|
45
|
+
}
|
|
46
|
+
// Call during component init, before setting a stack of your own.
|
|
47
|
+
// Returns a getter for this item's position within the parent stack (0,0 when not in one).
|
|
48
|
+
export function useSlot(size) {
|
|
49
|
+
const stack = getContext(STACK);
|
|
50
|
+
if (!stack)
|
|
51
|
+
return () => ({ x: 0, y: 0 });
|
|
52
|
+
const id = stack.register();
|
|
53
|
+
$effect(() => {
|
|
54
|
+
stack.setSize(id, size());
|
|
55
|
+
});
|
|
56
|
+
$effect(() => () => stack.unregister(id));
|
|
57
|
+
return () => stack.offset(id);
|
|
58
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type RadialLayout = {
|
|
2
|
+
count: number;
|
|
3
|
+
startAngle: number;
|
|
4
|
+
};
|
|
5
|
+
export declare const sectorAngle: (count: number) => number;
|
|
6
|
+
export declare function wedgeCenter(index: number, { count, startAngle }: RadialLayout): number;
|
|
7
|
+
export declare function wedgeAt(x: number, y: number, { count, startAngle }: RadialLayout, deadzone?: number): number;
|
|
8
|
+
export declare function polar(radius: number, angle: number): [number, number];
|
|
9
|
+
export declare function sectorPath(index: number, layout: RadialLayout, inner: number, outer: number, gap?: number): string;
|
|
10
|
+
export type Hand = 'left' | 'right';
|
|
11
|
+
export type ControllerPlacement = {
|
|
12
|
+
position: string;
|
|
13
|
+
rotation: string;
|
|
14
|
+
startAngle: number;
|
|
15
|
+
angleOffset: number;
|
|
16
|
+
labelRotation: number;
|
|
17
|
+
radius: number;
|
|
18
|
+
innerRadius: number;
|
|
19
|
+
};
|
|
20
|
+
export declare const ON_CONTROLLER: Record<Hand, ControllerPlacement>;
|
|
21
|
+
export declare class RadialInput {
|
|
22
|
+
open: boolean;
|
|
23
|
+
selected: number;
|
|
24
|
+
layout: RadialLayout;
|
|
25
|
+
deadzone: number;
|
|
26
|
+
disabled: (index: number) => boolean;
|
|
27
|
+
constructor(layout: RadialLayout, deadzone?: number, disabled?: (index: number) => boolean);
|
|
28
|
+
start(): void;
|
|
29
|
+
aim(x: number, y: number, angleOffset?: number): void;
|
|
30
|
+
point(index: number): void;
|
|
31
|
+
release(): number;
|
|
32
|
+
cancel(): void;
|
|
33
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Radial (pie) menu geometry and input, shared by the 2D and 3D renderers.
|
|
2
|
+
// Angles are in degrees, counterclockwise from +x (right), with +y up.
|
|
3
|
+
// Erasable TypeScript only — tests import it with Node's type stripping.
|
|
4
|
+
const DEG = Math.PI / 180;
|
|
5
|
+
const wrap = (deg) => ((deg % 360) + 360) % 360;
|
|
6
|
+
export const sectorAngle = (count) => 360 / count;
|
|
7
|
+
// Middle of wedge `index`.
|
|
8
|
+
export function wedgeCenter(index, { count, startAngle }) {
|
|
9
|
+
return startAngle + (index + 0.5) * sectorAngle(count);
|
|
10
|
+
}
|
|
11
|
+
// The wedge a direction points into, or -1 for no direction (inside the deadzone).
|
|
12
|
+
export function wedgeAt(x, y, { count, startAngle }, deadzone = 0) {
|
|
13
|
+
if (count <= 0 || Math.hypot(x, y) < deadzone)
|
|
14
|
+
return -1;
|
|
15
|
+
const angle = wrap(Math.atan2(y, x) / DEG - startAngle);
|
|
16
|
+
return Math.floor(angle / sectorAngle(count)) % count;
|
|
17
|
+
}
|
|
18
|
+
// Point at `radius` and `angle` (degrees), as [x, y] with +y up.
|
|
19
|
+
export function polar(radius, angle) {
|
|
20
|
+
return [radius * Math.cos(angle * DEG), radius * Math.sin(angle * DEG)];
|
|
21
|
+
}
|
|
22
|
+
// SVG path (y down) for a ring sector, with `gap` degrees trimmed off each side.
|
|
23
|
+
export function sectorPath(index, layout, inner, outer, gap = 0) {
|
|
24
|
+
const from = layout.startAngle + index * sectorAngle(layout.count) + gap / 2;
|
|
25
|
+
const to = from + sectorAngle(layout.count) - gap;
|
|
26
|
+
const large = to - from > 180 ? 1 : 0;
|
|
27
|
+
const pt = (r, a) => {
|
|
28
|
+
const [x, y] = polar(r, a);
|
|
29
|
+
return `${x.toFixed(3)} ${(-y).toFixed(3)}`;
|
|
30
|
+
};
|
|
31
|
+
// SVG's y axis points down, so counterclockwise on screen is sweep-flag 0.
|
|
32
|
+
return [
|
|
33
|
+
`M ${pt(outer, from)}`,
|
|
34
|
+
`A ${outer} ${outer} 0 ${large} 0 ${pt(outer, to)}`,
|
|
35
|
+
`L ${pt(inner, to)}`,
|
|
36
|
+
`A ${inner} ${inner} 0 ${large} 1 ${pt(inner, from)}`,
|
|
37
|
+
'Z',
|
|
38
|
+
].join(' ');
|
|
39
|
+
}
|
|
40
|
+
export const ON_CONTROLLER = {
|
|
41
|
+
right: { position: '-.02 -0.0766 -0.1428', rotation: '-60 -170 -90', startAngle: 0, angleOffset: 72, labelRotation: -90, radius: 32, innerRadius: 8 },
|
|
42
|
+
left: { position: '.02 -0.0766 -0.1428', rotation: '-60 170 90', startAngle: 108, angleOffset: -72, labelRotation: 90, radius: 32, innerRadius: 8 },
|
|
43
|
+
};
|
|
44
|
+
// Hold-to-open, aim, release-to-pick — the same for a VR controller (trigger +
|
|
45
|
+
// thumbstick), a held key + mouse, or a long press. Aiming back to the centre keeps
|
|
46
|
+
// the last wedge, so letting go of the stick before the trigger still picks it.
|
|
47
|
+
export class RadialInput {
|
|
48
|
+
open = false;
|
|
49
|
+
selected = -1;
|
|
50
|
+
layout;
|
|
51
|
+
deadzone;
|
|
52
|
+
// Wedges that can't be picked.
|
|
53
|
+
disabled;
|
|
54
|
+
constructor(layout, deadzone = 0.5, disabled = () => false) {
|
|
55
|
+
this.layout = layout;
|
|
56
|
+
this.deadzone = deadzone;
|
|
57
|
+
this.disabled = disabled;
|
|
58
|
+
}
|
|
59
|
+
start() {
|
|
60
|
+
this.open = true;
|
|
61
|
+
this.selected = -1;
|
|
62
|
+
}
|
|
63
|
+
// Aim with a direction (stick axes, or a pointer offset from the centre).
|
|
64
|
+
aim(x, y, angleOffset = 0) {
|
|
65
|
+
if (!this.open)
|
|
66
|
+
return;
|
|
67
|
+
const index = wedgeAt(x, y, { ...this.layout, startAngle: this.layout.startAngle + angleOffset }, this.deadzone);
|
|
68
|
+
if (index === -1)
|
|
69
|
+
return;
|
|
70
|
+
this.selected = this.disabled(index) ? -1 : index;
|
|
71
|
+
}
|
|
72
|
+
// Point straight at a wedge (hovering it with a cursor or laser).
|
|
73
|
+
point(index) {
|
|
74
|
+
if (this.open && !this.disabled(index))
|
|
75
|
+
this.selected = index;
|
|
76
|
+
}
|
|
77
|
+
// Closes the menu; returns the picked wedge, or -1.
|
|
78
|
+
release() {
|
|
79
|
+
if (!this.open)
|
|
80
|
+
return -1;
|
|
81
|
+
const picked = this.selected;
|
|
82
|
+
this.open = false;
|
|
83
|
+
this.selected = -1;
|
|
84
|
+
return picked;
|
|
85
|
+
}
|
|
86
|
+
cancel() {
|
|
87
|
+
this.open = false;
|
|
88
|
+
this.selected = -1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type TabEntry = {
|
|
2
|
+
readonly key: string;
|
|
3
|
+
readonly title: string;
|
|
4
|
+
readonly disabled: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type TabsContext = {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
register(tab: TabEntry): void;
|
|
9
|
+
unregister(tab: TabEntry): void;
|
|
10
|
+
readonly active: string | undefined;
|
|
11
|
+
};
|
|
12
|
+
export declare const setTabs: (tabs: TabsContext) => TabsContext;
|
|
13
|
+
export declare const getTabs: () => TabsContext | undefined;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Tabs <-> Tab: each Tab registers its key/title; Tabs draws the bar and tells
|
|
2
|
+
// each Tab whether it's the active one.
|
|
3
|
+
import { getContext, setContext } from 'svelte';
|
|
4
|
+
const TABS = Symbol('ui-tabs');
|
|
5
|
+
export const setTabs = (tabs) => setContext(TABS, tabs);
|
|
6
|
+
export const getTabs = () => getContext(TABS);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type TextState = {
|
|
2
|
+
text: string;
|
|
3
|
+
caret: number;
|
|
4
|
+
};
|
|
5
|
+
export type Splice = {
|
|
6
|
+
at: number;
|
|
7
|
+
remove: number;
|
|
8
|
+
insert: string;
|
|
9
|
+
};
|
|
10
|
+
export type Edit = {
|
|
11
|
+
state: TextState;
|
|
12
|
+
splice: Splice;
|
|
13
|
+
};
|
|
14
|
+
export type EditOptions = {
|
|
15
|
+
multiline?: boolean;
|
|
16
|
+
maxLength?: number;
|
|
17
|
+
};
|
|
18
|
+
export type EditAction = {
|
|
19
|
+
type: 'insert';
|
|
20
|
+
text: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'backspace';
|
|
23
|
+
} | {
|
|
24
|
+
type: 'delete';
|
|
25
|
+
} | {
|
|
26
|
+
type: 'left';
|
|
27
|
+
} | {
|
|
28
|
+
type: 'right';
|
|
29
|
+
} | {
|
|
30
|
+
type: 'home';
|
|
31
|
+
} | {
|
|
32
|
+
type: 'end';
|
|
33
|
+
};
|
|
34
|
+
export declare function clampCaret(text: string, caret: number): number;
|
|
35
|
+
export declare function sanitize(text: string, multiline?: boolean): string;
|
|
36
|
+
export declare function applySplice(text: string, { at, remove, insert }: Splice): string;
|
|
37
|
+
export declare function edit(state: TextState, action: EditAction, { multiline, maxLength }?: EditOptions): Edit | null;
|
|
38
|
+
export declare function keyToAction(event: {
|
|
39
|
+
key: string;
|
|
40
|
+
ctrlKey?: boolean;
|
|
41
|
+
metaKey?: boolean;
|
|
42
|
+
altKey?: boolean;
|
|
43
|
+
}): EditAction | 'enter' | 'escape' | null;
|
|
44
|
+
export declare function caretX(index: number, positions: ArrayLike<number> | null | undefined, length: number): number;
|
|
45
|
+
export declare function caretIndexAt(x: number, positions: ArrayLike<number> | null | undefined, length: number): number;
|
|
46
|
+
export declare function scrollToCaret(caret: number, scroll: number, viewWidth: number, textWidth: number): number;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Text editing for the 3D Input (and its VR keyboard): a value plus a caret, changed
|
|
2
|
+
// by splices. Every edit is one splice (`at`, `remove` chars, `insert` text), the form
|
|
3
|
+
// a shared, networked document would send. Erasable TypeScript only (tested in Node).
|
|
4
|
+
const isHigh = (code) => code >= 0xd800 && code <= 0xdbff;
|
|
5
|
+
const isLow = (code) => code >= 0xdc00 && code <= 0xdfff;
|
|
6
|
+
export function clampCaret(text, caret) {
|
|
7
|
+
const c = Math.min(text.length, Math.max(0, Math.round(caret) || 0));
|
|
8
|
+
// Never inside a surrogate pair (emoji etc.).
|
|
9
|
+
return c > 0 && c < text.length && isLow(text.charCodeAt(c)) && isHigh(text.charCodeAt(c - 1)) ? c - 1 : c;
|
|
10
|
+
}
|
|
11
|
+
// One character step left/right, keeping surrogate pairs together.
|
|
12
|
+
function step(text, caret, direction) {
|
|
13
|
+
const next = caret + direction;
|
|
14
|
+
if (next < 0 || next > text.length)
|
|
15
|
+
return caret;
|
|
16
|
+
if (direction < 0 && next > 0 && isLow(text.charCodeAt(next)) && isHigh(text.charCodeAt(next - 1)))
|
|
17
|
+
return next - 1;
|
|
18
|
+
if (direction > 0 && next < text.length && isLow(text.charCodeAt(next)) && isHigh(text.charCodeAt(next - 1)))
|
|
19
|
+
return next + 1;
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
// What may be typed: no control characters; newlines only when multiline (else spaces).
|
|
23
|
+
export function sanitize(text, multiline = false) {
|
|
24
|
+
const lines = text.replace(/\r\n?/g, '\n');
|
|
25
|
+
const flat = multiline ? lines : lines.replace(/\n+/g, ' ');
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
return flat.replace(/\t/g, ' ').replace(/[\u0000-\u0009\u000B-\u001F\u007F]/g, '');
|
|
28
|
+
}
|
|
29
|
+
export function applySplice(text, { at, remove, insert }) {
|
|
30
|
+
return text.slice(0, at) + insert + text.slice(at + remove);
|
|
31
|
+
}
|
|
32
|
+
function spliceEdit(state, splice, caret) {
|
|
33
|
+
if (!splice.remove && !splice.insert)
|
|
34
|
+
return null;
|
|
35
|
+
return { state: { text: applySplice(state.text, splice), caret }, splice };
|
|
36
|
+
}
|
|
37
|
+
// Applies an editing action. Returns null when nothing changes (e.g. backspace at the
|
|
38
|
+
// start); caret-only moves return a state with an empty splice.
|
|
39
|
+
export function edit(state, action, { multiline = false, maxLength } = {}) {
|
|
40
|
+
const text = state.text;
|
|
41
|
+
const caret = clampCaret(text, state.caret);
|
|
42
|
+
const move = (to) => (to === caret ? null : { state: { text, caret: to }, splice: { at: to, remove: 0, insert: '' } });
|
|
43
|
+
switch (action.type) {
|
|
44
|
+
case 'insert': {
|
|
45
|
+
let insert = sanitize(action.text, multiline);
|
|
46
|
+
if (maxLength !== undefined)
|
|
47
|
+
insert = insert.slice(0, Math.max(0, maxLength - text.length));
|
|
48
|
+
// Don't cut a surrogate pair in half at the limit.
|
|
49
|
+
if (insert && isHigh(insert.charCodeAt(insert.length - 1)))
|
|
50
|
+
insert = insert.slice(0, -1);
|
|
51
|
+
return spliceEdit({ text, caret }, { at: caret, remove: 0, insert }, caret + insert.length);
|
|
52
|
+
}
|
|
53
|
+
case 'backspace': {
|
|
54
|
+
const from = step(text, caret, -1);
|
|
55
|
+
return spliceEdit({ text, caret }, { at: from, remove: caret - from, insert: '' }, from);
|
|
56
|
+
}
|
|
57
|
+
case 'delete': {
|
|
58
|
+
const to = step(text, caret, 1);
|
|
59
|
+
return spliceEdit({ text, caret }, { at: caret, remove: to - caret, insert: '' }, caret);
|
|
60
|
+
}
|
|
61
|
+
case 'left':
|
|
62
|
+
return move(step(text, caret, -1));
|
|
63
|
+
case 'right':
|
|
64
|
+
return move(step(text, caret, 1));
|
|
65
|
+
case 'home':
|
|
66
|
+
return move(0);
|
|
67
|
+
case 'end':
|
|
68
|
+
return move(text.length);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Physical keyboard → editing action. `enter` / `escape` are the field's own (submit /
|
|
72
|
+
// cancel). Returns null for keys a text field leaves alone (shortcuts with Cmd/Ctrl,
|
|
73
|
+
// function keys), so they keep working.
|
|
74
|
+
export function keyToAction(event) {
|
|
75
|
+
if (event.ctrlKey || event.metaKey)
|
|
76
|
+
return null;
|
|
77
|
+
switch (event.key) {
|
|
78
|
+
case 'Enter':
|
|
79
|
+
return 'enter';
|
|
80
|
+
case 'Escape':
|
|
81
|
+
return 'escape';
|
|
82
|
+
case 'Backspace':
|
|
83
|
+
return { type: 'backspace' };
|
|
84
|
+
case 'Delete':
|
|
85
|
+
return { type: 'delete' };
|
|
86
|
+
case 'ArrowLeft':
|
|
87
|
+
return { type: 'left' };
|
|
88
|
+
case 'ArrowRight':
|
|
89
|
+
return { type: 'right' };
|
|
90
|
+
case 'Home':
|
|
91
|
+
case 'ArrowUp':
|
|
92
|
+
return { type: 'home' };
|
|
93
|
+
case 'End':
|
|
94
|
+
case 'ArrowDown':
|
|
95
|
+
return { type: 'end' };
|
|
96
|
+
}
|
|
97
|
+
// Printable characters have a one-character key (two for astral, e.g. emoji).
|
|
98
|
+
return [...event.key].length === 1 ? { type: 'insert', text: event.key } : null;
|
|
99
|
+
}
|
|
100
|
+
// ---- Caret placement (troika `caretPositions`: startX, endX, bottomY, topY per char) ----
|
|
101
|
+
// x of the caret before character `index` (after the last one at index = length).
|
|
102
|
+
export function caretX(index, positions, length) {
|
|
103
|
+
if (!positions || !length)
|
|
104
|
+
return 0;
|
|
105
|
+
const i = Math.min(Math.max(index, 0), length);
|
|
106
|
+
return i < length ? positions[i * 4] : positions[(length - 1) * 4 + 1];
|
|
107
|
+
}
|
|
108
|
+
// The caret index nearest to x (text-local) — where a click puts the caret.
|
|
109
|
+
export function caretIndexAt(x, positions, length) {
|
|
110
|
+
if (!positions || !length)
|
|
111
|
+
return 0;
|
|
112
|
+
let best = 0;
|
|
113
|
+
let bestDistance = Infinity;
|
|
114
|
+
for (let i = 0; i <= length; i++) {
|
|
115
|
+
const distance = Math.abs(caretX(i, positions, length) - x);
|
|
116
|
+
if (distance < bestDistance) {
|
|
117
|
+
best = i;
|
|
118
|
+
bestDistance = distance;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return best;
|
|
122
|
+
}
|
|
123
|
+
// Horizontal scroll (how far the text is shifted left) that keeps the caret inside a
|
|
124
|
+
// window `viewWidth` wide, moving as little as possible; `textWidth` stops it
|
|
125
|
+
// scrolling past the end of the text.
|
|
126
|
+
export function scrollToCaret(caret, scroll, viewWidth, textWidth) {
|
|
127
|
+
let next = scroll;
|
|
128
|
+
if (caret - next > viewWidth)
|
|
129
|
+
next = caret - viewWidth;
|
|
130
|
+
if (caret - next < 0)
|
|
131
|
+
next = caret;
|
|
132
|
+
return Math.min(Math.max(next, 0), Math.max(0, textWidth - viewWidth));
|
|
133
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EnterKeyHint } from './keyboard-layout';
|
|
2
|
+
import { type EditAction } from './text-edit';
|
|
3
|
+
export type Editable = {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly caret: number;
|
|
6
|
+
readonly placeholder: string;
|
|
7
|
+
readonly inputmode: 'text' | 'numeric';
|
|
8
|
+
readonly enterKeyHint: EnterKeyHint;
|
|
9
|
+
readonly el: Element | undefined;
|
|
10
|
+
edit(action: EditAction): void;
|
|
11
|
+
setCaret(index: number): void;
|
|
12
|
+
enter(): void;
|
|
13
|
+
done(): void;
|
|
14
|
+
cancel(): void;
|
|
15
|
+
};
|
|
16
|
+
declare class TextFocus {
|
|
17
|
+
current: Editable | null;
|
|
18
|
+
readonly keyboards: Set<Element>;
|
|
19
|
+
focus(field: Editable): void;
|
|
20
|
+
blur(field: Editable): void;
|
|
21
|
+
contains(target: unknown): boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare const textFocus: TextFocus;
|
|
24
|
+
export {};
|