@inkra/react 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +186 -1
  2. package/dist/PencilIcon.js +14 -16
  3. package/dist/PencilLine.js +18 -12
  4. package/dist/PencilText.js +192 -59
  5. package/dist/alert-dialog/index.d.ts +64 -0
  6. package/dist/alert-dialog/index.js +111 -0
  7. package/dist/audio-player/index.d.ts +60 -0
  8. package/dist/audio-player/index.js +101 -0
  9. package/dist/carousel/index.d.ts +51 -0
  10. package/dist/carousel/index.js +290 -0
  11. package/dist/choice-grid/index.d.ts +38 -0
  12. package/dist/choice-grid/index.js +85 -0
  13. package/dist/color-picker/color.d.ts +15 -0
  14. package/dist/color-picker/color.js +55 -0
  15. package/dist/color-picker/index.d.ts +31 -0
  16. package/dist/color-picker/index.js +69 -0
  17. package/dist/command/index.d.ts +45 -0
  18. package/dist/command/index.js +39 -0
  19. package/dist/context-menu/index.d.ts +76 -0
  20. package/dist/context-menu/index.js +241 -0
  21. package/dist/data-table/index.d.ts +47 -0
  22. package/dist/data-table/index.js +91 -0
  23. package/dist/date-picker/index.d.ts +50 -0
  24. package/dist/date-picker/index.js +180 -0
  25. package/dist/display/ProgressRing.d.ts +6 -0
  26. package/dist/display/ProgressRing.js +146 -0
  27. package/dist/display/index.d.ts +27 -0
  28. package/dist/display/index.js +18 -2
  29. package/dist/dockable-panel/index.d.ts +41 -0
  30. package/dist/dockable-panel/index.js +253 -0
  31. package/dist/dockable-panel/model.d.ts +21 -0
  32. package/dist/dockable-panel/model.js +61 -0
  33. package/dist/drawer/index.d.ts +63 -0
  34. package/dist/drawer/index.js +88 -0
  35. package/dist/dropdown-menu/index.d.ts +1 -1
  36. package/dist/dropdown-menu/index.js +3 -2
  37. package/dist/editable/index.d.ts +53 -0
  38. package/dist/editable/index.js +162 -0
  39. package/dist/empty/index.d.ts +21 -0
  40. package/dist/empty/index.js +23 -0
  41. package/dist/flip/index.d.ts +49 -0
  42. package/dist/flip/index.js +138 -0
  43. package/dist/form/EditablePencilText.js +188 -143
  44. package/dist/form/Input.js +1 -1
  45. package/dist/form/Textarea.d.ts +12 -0
  46. package/dist/form/Textarea.js +83 -4
  47. package/dist/form/index.d.ts +1 -1
  48. package/dist/heatmap/index.d.ts +59 -0
  49. package/dist/heatmap/index.js +248 -0
  50. package/dist/hotkey/index.d.ts +37 -0
  51. package/dist/hotkey/index.js +130 -0
  52. package/dist/hotkey/model.d.ts +27 -0
  53. package/dist/hotkey/model.js +97 -0
  54. package/dist/hover-card/index.d.ts +33 -0
  55. package/dist/hover-card/index.js +141 -0
  56. package/dist/image-cropper/index.d.ts +82 -0
  57. package/dist/image-cropper/index.js +261 -0
  58. package/dist/image-cropper/model.d.ts +42 -0
  59. package/dist/image-cropper/model.js +94 -0
  60. package/dist/image-viewer/index.d.ts +79 -0
  61. package/dist/image-viewer/index.js +220 -0
  62. package/dist/image-viewer/model.d.ts +9 -0
  63. package/dist/image-viewer/model.js +18 -0
  64. package/dist/index.d.ts +47 -0
  65. package/dist/index.js +47 -0
  66. package/dist/input-number/index.d.ts +6 -0
  67. package/dist/input-number/index.js +126 -15
  68. package/dist/input-number/model.d.ts +13 -0
  69. package/dist/input-number/model.js +50 -0
  70. package/dist/internal/cache.d.ts +1 -1
  71. package/dist/internal/cache.js +1 -1
  72. package/dist/internal/renderTokens.d.ts +10 -0
  73. package/dist/internal/renderTokens.js +13 -0
  74. package/dist/internal/surfaceRenderer.d.ts +14 -6
  75. package/dist/internal/surfaceRenderer.js +15 -12
  76. package/dist/internal/textRenderer.d.ts +1 -0
  77. package/dist/internal/textRenderer.js +33 -7
  78. package/dist/internal/usePencilSurface.js +16 -8
  79. package/dist/internal/useSelectionDrawing.js +15 -11
  80. package/dist/journey-map/MapScale.js +3 -1
  81. package/dist/journey-map/index.js +3 -1
  82. package/dist/keypad/index.d.ts +23 -0
  83. package/dist/keypad/index.js +63 -0
  84. package/dist/keypad/model.d.ts +20 -0
  85. package/dist/keypad/model.js +33 -0
  86. package/dist/layout/index.d.ts +63 -0
  87. package/dist/layout/index.js +62 -0
  88. package/dist/log/index.d.ts +45 -0
  89. package/dist/log/index.js +159 -0
  90. package/dist/log/model.d.ts +16 -0
  91. package/dist/log/model.js +13 -0
  92. package/dist/node-graph/index.d.ts +123 -0
  93. package/dist/node-graph/index.js +304 -0
  94. package/dist/number-ticker/index.d.ts +31 -0
  95. package/dist/number-ticker/index.js +135 -0
  96. package/dist/number-ticker/model.d.ts +41 -0
  97. package/dist/number-ticker/model.js +163 -0
  98. package/dist/number-ticker/precision.d.ts +7 -0
  99. package/dist/number-ticker/precision.js +134 -0
  100. package/dist/overflow-list/index.d.ts +39 -0
  101. package/dist/overflow-list/index.js +230 -0
  102. package/dist/overflow-list/layout.d.ts +19 -0
  103. package/dist/overflow-list/layout.js +44 -0
  104. package/dist/overlay/Overlay.d.ts +1 -1
  105. package/dist/overlay/Overlay.js +4 -2
  106. package/dist/overlay/Popover.d.ts +1 -1
  107. package/dist/overlay/Popover.js +3 -2
  108. package/dist/overlay/themeBridge.d.ts +12 -1
  109. package/dist/overlay/themeBridge.js +27 -1
  110. package/dist/pagination/index.d.ts +23 -0
  111. package/dist/pagination/index.js +41 -0
  112. package/dist/pagination/model.d.ts +4 -0
  113. package/dist/pagination/model.js +42 -0
  114. package/dist/pan-zoom/index.d.ts +71 -0
  115. package/dist/pan-zoom/index.js +175 -0
  116. package/dist/radar/index.d.ts +56 -0
  117. package/dist/radar/index.js +220 -0
  118. package/dist/range-slider/index.d.ts +16 -0
  119. package/dist/range-slider/index.js +22 -0
  120. package/dist/rating/index.d.ts +28 -0
  121. package/dist/rating/index.js +127 -0
  122. package/dist/resizable/index.d.ts +32 -0
  123. package/dist/resizable/index.js +50 -0
  124. package/dist/scroll-area/index.d.ts +36 -0
  125. package/dist/scroll-area/index.js +44 -0
  126. package/dist/segmented-progress/SegmentedProgress.d.ts +17 -0
  127. package/dist/segmented-progress/SegmentedProgress.js +42 -0
  128. package/dist/slider/index.js +1 -1
  129. package/dist/slot/index.d.ts +24 -0
  130. package/dist/slot/index.js +29 -0
  131. package/dist/sortable/index.d.ts +50 -0
  132. package/dist/sortable/index.js +283 -0
  133. package/dist/sortable/model.d.ts +30 -0
  134. package/dist/sortable/model.js +64 -0
  135. package/dist/sparkline/index.d.ts +19 -0
  136. package/dist/sparkline/index.js +240 -0
  137. package/dist/sparkline/model.d.ts +16 -0
  138. package/dist/sparkline/model.js +28 -0
  139. package/dist/steps/index.d.ts +16 -0
  140. package/dist/steps/index.js +37 -0
  141. package/dist/styles.css +4957 -269
  142. package/dist/tag-input/index.d.ts +69 -0
  143. package/dist/tag-input/index.js +325 -0
  144. package/dist/tag-input/model.d.ts +14 -0
  145. package/dist/tag-input/model.js +38 -0
  146. package/dist/time-picker/index.d.ts +15 -0
  147. package/dist/time-picker/index.js +168 -0
  148. package/dist/timeline/index.d.ts +14 -0
  149. package/dist/timeline/index.js +16 -0
  150. package/dist/toast/index.d.ts +27 -0
  151. package/dist/toast/index.js +36 -0
  152. package/dist/toggle-group/index.d.ts +17 -0
  153. package/dist/toggle-group/index.js +26 -0
  154. package/dist/tokens.css +10 -9
  155. package/dist/tooltip/Tooltip.d.ts +1 -1
  156. package/dist/tooltip/Tooltip.js +3 -2
  157. package/dist/transfer/index.d.ts +64 -0
  158. package/dist/transfer/index.js +174 -0
  159. package/dist/transfer/model.d.ts +12 -0
  160. package/dist/transfer/model.js +33 -0
  161. package/dist/tree/index.d.ts +39 -0
  162. package/dist/tree/index.js +36 -0
  163. package/dist/tree-select/index.d.ts +59 -0
  164. package/dist/tree-select/index.js +142 -0
  165. package/dist/tree-select/model.d.ts +31 -0
  166. package/dist/tree-select/model.js +135 -0
  167. package/dist/typewriter/index.d.ts +21 -0
  168. package/dist/typewriter/index.js +101 -0
  169. package/dist/typewriter/model.d.ts +7 -0
  170. package/dist/typewriter/model.js +45 -0
  171. package/dist/upload/index.d.ts +59 -0
  172. package/dist/upload/index.js +230 -0
  173. package/dist/virtual-list/index.d.ts +44 -0
  174. package/dist/virtual-list/index.js +129 -0
  175. package/dist/virtual-list/model.d.ts +7 -0
  176. package/dist/virtual-list/model.js +27 -0
  177. package/package.json +23 -3
@@ -1,18 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
3
  import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
4
4
  import { Button } from '../Button.js';
5
5
  import { PencilIcon } from '../PencilIcon.js';
6
6
  import { Input } from '../form/Input.js';
7
7
  import { useResolvedControlProps } from '../form/controlProps.js';
8
8
  import { Tooltip } from '../tooltip/Tooltip.js';
9
+ import { normalizeInputNumberRepeat, resolveInputNumberStep, stepInputNumber, } from './model.js';
9
10
  // Lucide Plus / Minus ISC geometry uses the shared pencil renderer.
10
11
  const plusPaths = [{ d: 'M5 12h14' }, { d: 'M12 5v14' }];
11
12
  const minusPaths = [{ d: 'M5 12h14' }];
12
13
  const finiteValue = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
13
14
  const format = (value) => String(finiteValue(value) ?? '');
14
15
  const numberFromDraft = (draft) => draft === '' ? null : finiteValue(Number(draft));
15
- export const InputNumber = forwardRef(function InputNumber({ classNames, controls = true, decrementButtonLabel = 'Decrease value', defaultValue, disabled, incrementButtonLabel = 'Increase value', inputMode = 'decimal', max, min, onChange, onValueChange, readOnly, size = 'md', step = 1, textAlign = 'center', value, ...props }, forwardedRef) {
16
+ export const InputNumber = forwardRef(function InputNumber({ classNames, controls = true, decrementButtonLabel = 'Decrease value', defaultValue, disabled, incrementButtonLabel = 'Increase value', inputMode = 'decimal', max, min, onChange, onBlur, onKeyDown, onValueChange, pressAndHold = true, readOnly, repeatDelay = 400, repeatInterval = 80, size = 'md', step = 1, stepModifiers, textAlign = 'center', value, ...props }, forwardedRef) {
16
17
  const inputRef = useRef(null);
17
18
  const isControlled = value !== undefined;
18
19
  const [draft, setDraft] = useState(() => format(isControlled ? value : defaultValue));
@@ -21,6 +22,50 @@ export const InputNumber = forwardRef(function InputNumber({ classNames, control
21
22
  const latest = useRef({ isControlled, value, defaultValue });
22
23
  latest.current = { isControlled, value, defaultValue };
23
24
  useImperativeHandle(forwardedRef, () => inputRef.current);
25
+ const holdRef = useRef(null);
26
+ const timerRef = useRef(undefined);
27
+ const suppressClickRef = useRef(false);
28
+ const stopHolding = useCallback(() => {
29
+ clearTimeout(timerRef.current);
30
+ timerRef.current = undefined;
31
+ const hold = holdRef.current;
32
+ holdRef.current = null;
33
+ if (hold?.button.hasPointerCapture?.(hold.pointerId)) {
34
+ try {
35
+ hold.button.releasePointerCapture(hold.pointerId);
36
+ }
37
+ catch { /* The pointer may already be inactive. */ }
38
+ }
39
+ }, []);
40
+ useEffect(() => {
41
+ const onVisibilityChange = () => { if (document.hidden)
42
+ stopHolding(); };
43
+ const onPointerEnd = (event) => {
44
+ if (event.pointerId === holdRef.current?.pointerId)
45
+ stopHolding();
46
+ };
47
+ const updateModifiers = (event) => {
48
+ if (holdRef.current)
49
+ holdRef.current.modifiers = {
50
+ shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey,
51
+ };
52
+ };
53
+ window.addEventListener('blur', stopHolding);
54
+ window.addEventListener('pointerup', onPointerEnd);
55
+ window.addEventListener('pointercancel', onPointerEnd);
56
+ window.addEventListener('keydown', updateModifiers);
57
+ window.addEventListener('keyup', updateModifiers);
58
+ document.addEventListener('visibilitychange', onVisibilityChange);
59
+ return () => {
60
+ stopHolding();
61
+ window.removeEventListener('blur', stopHolding);
62
+ window.removeEventListener('pointerup', onPointerEnd);
63
+ window.removeEventListener('pointercancel', onPointerEnd);
64
+ window.removeEventListener('keydown', updateModifiers);
65
+ window.removeEventListener('keyup', updateModifiers);
66
+ document.removeEventListener('visibilitychange', onVisibilityChange);
67
+ };
68
+ }, [stopHolding]);
24
69
  useBrowserLayoutEffect(() => {
25
70
  if (isControlled && currentValue !== finiteValue(value))
26
71
  setDraft(format(value));
@@ -32,6 +77,7 @@ export const InputNumber = forwardRef(function InputNumber({ classNames, control
32
77
  return;
33
78
  let resetTimer;
34
79
  const reset = (event) => {
80
+ stopHolding();
35
81
  clearTimeout(resetTimer);
36
82
  // Native reset runs after the event; restore the public default, not the latest draft.
37
83
  resetTimer = setTimeout(() => {
@@ -45,33 +91,98 @@ export const InputNumber = forwardRef(function InputNumber({ classNames, control
45
91
  };
46
92
  form.addEventListener('reset', reset);
47
93
  return () => { clearTimeout(resetTimer); form.removeEventListener('reset', reset); };
48
- }, [props.form]);
94
+ }, [props.form, stopHolding]);
49
95
  const cannotStep = state.disabled || state.readOnly || step === 'any' || (min !== undefined && max !== undefined && min > max);
50
96
  const decreaseDisabled = cannotStep || (currentValue !== null && min !== undefined && currentValue <= min);
51
97
  const increaseDisabled = cannotStep || (currentValue !== null && max !== undefined && currentValue >= max);
52
- function changeStep(direction) {
98
+ useBrowserLayoutEffect(() => {
99
+ const direction = holdRef.current?.direction;
100
+ if (cannotStep || !controls || !pressAndHold
101
+ || (direction === 'increment' && increaseDisabled)
102
+ || (direction === 'decrement' && decreaseDisabled))
103
+ stopHolding();
104
+ }, [cannotStep, controls, decreaseDisabled, increaseDisabled, pressAndHold, stopHolding]);
105
+ function changeStep(direction, modifiers) {
53
106
  const input = inputRef.current;
54
107
  if (!input || input.matches(':disabled') || input.readOnly || cannotStep)
55
- return;
56
- const before = input.value;
57
- // Native stepping applies the step base and decimal rounding without a custom parser.
58
- if (direction === 'increment')
59
- input.stepUp();
60
- else
61
- input.stepDown();
108
+ return false;
109
+ const changed = stepInputNumber(input, direction, resolveInputNumberStep(modifiers, stepModifiers));
62
110
  input.focus({ preventScroll: true });
63
- if (input.value === before)
64
- return;
111
+ if (!changed)
112
+ return false;
65
113
  setDraft(input.value);
66
114
  onValueChange?.(Number.isFinite(input.valueAsNumber) ? input.valueAsNumber : null, { reason: direction });
115
+ return true;
116
+ }
117
+ const stepRef = useRef(changeStep);
118
+ stepRef.current = changeStep;
119
+ const intervalRef = useRef(repeatInterval);
120
+ intervalRef.current = repeatInterval;
121
+ function startHolding(event, direction) {
122
+ if (event.button !== 0 || !event.isPrimary)
123
+ return;
124
+ event.preventDefault();
125
+ stopHolding();
126
+ suppressClickRef.current = false;
127
+ if (!pressAndHold)
128
+ return;
129
+ suppressClickRef.current = true;
130
+ const modifiers = {
131
+ shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey,
132
+ };
133
+ if (!changeStep(direction, modifiers))
134
+ return;
135
+ const button = event.currentTarget;
136
+ if (!button.isConnected || button.disabled)
137
+ return;
138
+ holdRef.current = { button, pointerId: event.pointerId, direction, modifiers };
139
+ try {
140
+ button.setPointerCapture(event.pointerId);
141
+ }
142
+ catch { /* Window listeners retain release handling. */ }
143
+ const repeat = () => {
144
+ const hold = holdRef.current;
145
+ if (!hold || document.hidden || !stepRef.current(hold.direction, hold.modifiers)) {
146
+ stopHolding();
147
+ return;
148
+ }
149
+ if (holdRef.current)
150
+ timerRef.current = setTimeout(repeat, normalizeInputNumberRepeat(intervalRef.current, 80, 32));
151
+ };
152
+ timerRef.current = setTimeout(repeat, normalizeInputNumberRepeat(repeatDelay, 400, 150));
153
+ }
154
+ function buttonEvents(direction) {
155
+ return {
156
+ onPointerDown: (event) => startHolding(event, direction),
157
+ onPointerUp: stopHolding,
158
+ onPointerCancel: stopHolding,
159
+ onLostPointerCapture: stopHolding,
160
+ onBlur: stopHolding,
161
+ onClick: (event) => {
162
+ if (event.detail > 0 && suppressClickRef.current) {
163
+ suppressClickRef.current = false;
164
+ return;
165
+ }
166
+ suppressClickRef.current = false;
167
+ changeStep(direction, event);
168
+ },
169
+ };
67
170
  }
68
171
  const iconSize = size === 'sm' ? 18 : size === 'lg' ? 26 : 22;
69
172
  const buttonSize = size === 'sm' ? 'icon-sm' : size === 'lg' ? 'icon-lg' : 'icon';
70
- return _jsx(Input, { ...props, ref: inputRef, type: "number", inputMode: inputMode, textAlign: textAlign, clearable: false, size: size, min: min, max: max, step: step, disabled: state.disabled, readOnly: state.readOnly, value: draft, classNames: { ...classNames, root: ['inkra-input-number', classNames?.root].filter(Boolean).join(' ') }, onChange: event => {
173
+ return _jsx(Input, { ...props, ref: inputRef, type: "number", inputMode: inputMode, textAlign: textAlign, clearable: false, size: size, min: min, max: max, step: step, disabled: state.disabled, readOnly: state.readOnly, value: draft, classNames: { ...classNames, root: ['inkra-input-number', classNames?.root].filter(Boolean).join(' ') }, onBlur: event => { stopHolding(); onBlur?.(event); }, onKeyDown: event => {
174
+ onKeyDown?.(event);
175
+ if (event.defaultPrevented || event.nativeEvent.isComposing || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown'))
176
+ return;
177
+ if (resolveInputNumberStep(event, stepModifiers) === undefined)
178
+ return;
179
+ event.preventDefault();
180
+ changeStep(event.key === 'ArrowUp' ? 'increment' : 'decrement', event);
181
+ }, onChange: event => {
71
182
  const input = event.currentTarget;
72
183
  const nextValue = Number.isFinite(input.valueAsNumber) ? input.valueAsNumber : null;
73
184
  setDraft(input.value);
74
185
  onChange?.(event);
75
186
  onValueChange?.(nextValue, { reason: 'input' });
76
- }, startAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: decreaseDisabled, "aria-label": decrementButtonLabel, onPointerDown: event => event.preventDefault(), onClick: () => changeStep('decrement'), children: _jsx(PencilIcon, { paths: minusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: decrementButtonLabel }) })] }) }) }) : undefined, endAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: increaseDisabled, "aria-label": incrementButtonLabel, onPointerDown: event => event.preventDefault(), onClick: () => changeStep('increment'), children: _jsx(PencilIcon, { paths: plusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: incrementButtonLabel }) })] }) }) }) : undefined });
187
+ }, startAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: decreaseDisabled, "aria-label": decrementButtonLabel, ...buttonEvents('decrement'), children: _jsx(PencilIcon, { paths: minusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: decrementButtonLabel }) })] }) }) }) : undefined, endAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: increaseDisabled, "aria-label": incrementButtonLabel, ...buttonEvents('increment'), children: _jsx(PencilIcon, { paths: plusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: incrementButtonLabel }) })] }) }) }) : undefined });
77
188
  });
@@ -0,0 +1,13 @@
1
+ export interface InputNumberStepModifier {
2
+ shiftKey?: boolean;
3
+ altKey?: boolean;
4
+ ctrlKey?: boolean;
5
+ metaKey?: boolean;
6
+ step: number;
7
+ }
8
+ export type InputNumberModifierState = Pick<KeyboardEvent, 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>;
9
+ export type InputNumberStepDirection = 'increment' | 'decrement';
10
+ export declare const defaultInputNumberStepModifiers: readonly InputNumberStepModifier[];
11
+ export declare function resolveInputNumberStep(modifiers: InputNumberModifierState, rules?: readonly InputNumberStepModifier[]): number | undefined;
12
+ export declare function normalizeInputNumberRepeat(value: number | undefined, fallback: number, minimum: number): number;
13
+ export declare function stepInputNumber(input: HTMLInputElement, direction: InputNumberStepDirection, modifierStep?: number): boolean;
@@ -0,0 +1,50 @@
1
+ export const defaultInputNumberStepModifiers = [
2
+ { shiftKey: true, step: 10 },
3
+ ];
4
+ export function resolveInputNumberStep(modifiers, rules = defaultInputNumberStepModifiers) {
5
+ return rules.find(rule => Number.isFinite(rule.step) && rule.step > 0
6
+ && Boolean(rule.shiftKey) === modifiers.shiftKey
7
+ && Boolean(rule.altKey) === modifiers.altKey
8
+ && Boolean(rule.ctrlKey) === modifiers.ctrlKey
9
+ && Boolean(rule.metaKey) === modifiers.metaKey)?.step;
10
+ }
11
+ export function normalizeInputNumberRepeat(value, fallback, minimum) {
12
+ return value === undefined || !Number.isFinite(value)
13
+ ? fallback
14
+ : Math.min(60000, Math.max(minimum, value));
15
+ }
16
+ export function stepInputNumber(input, direction, modifierStep) {
17
+ const before = input.value;
18
+ const min = input.min === '' ? -Infinity : Number(input.min);
19
+ const max = input.max === '' ? Infinity : Number(input.max);
20
+ if (input.disabled || input.readOnly || input.step === 'any' || min > max)
21
+ return false;
22
+ if ((direction === 'increment' && input.valueAsNumber >= max)
23
+ || (direction === 'decrement' && input.valueAsNumber <= min))
24
+ return false;
25
+ if (modifierStep === undefined) {
26
+ if (direction === 'increment')
27
+ input.stepUp();
28
+ else
29
+ input.stepDown();
30
+ }
31
+ else {
32
+ // A detached native input anchors the step base at the current value, so
33
+ // modifier steps are absolute deltas without floating-point arithmetic.
34
+ const probe = input.cloneNode(false);
35
+ probe.removeAttribute('min');
36
+ probe.removeAttribute('max');
37
+ probe.step = String(modifierStep);
38
+ probe.defaultValue = before || '0';
39
+ probe.value = before;
40
+ if (direction === 'increment')
41
+ probe.stepUp();
42
+ else
43
+ probe.stepDown();
44
+ const next = probe.valueAsNumber;
45
+ if (!Number.isFinite(next))
46
+ return false;
47
+ input.value = String(Math.min(Number.isFinite(max) ? max : Infinity, Math.max(Number.isFinite(min) ? min : -Infinity, next)));
48
+ }
49
+ return before !== input.value;
50
+ }
@@ -1,4 +1,4 @@
1
- export declare const PENCIL_RENDERER_VERSION = "inkra-pencil-v4";
1
+ export declare const PENCIL_RENDERER_VERSION = "inkra-pencil-v5";
2
2
  export declare class BoundedCache<Value> {
3
3
  private readonly maxEntries;
4
4
  private readonly maxCost;
@@ -1,4 +1,4 @@
1
- export const PENCIL_RENDERER_VERSION = 'inkra-pencil-v4';
1
+ export const PENCIL_RENDERER_VERSION = 'inkra-pencil-v5';
2
2
  export class BoundedCache {
3
3
  constructor(maxEntries, maxCost) {
4
4
  this.maxEntries = maxEntries;
@@ -0,0 +1,10 @@
1
+ export declare const pencilRenderDefaults: Readonly<{
2
+ strokeWidth: 2;
3
+ strokeRoughness: 1.5;
4
+ washStrength: 0.8;
5
+ washSpacing: 2;
6
+ washRoughness: 3;
7
+ washAngle: -45;
8
+ }>;
9
+ export declare const maxStrokeRoughness = 4;
10
+ export declare function readRenderToken(style: Pick<CSSStyleDeclaration, 'getPropertyValue'>, name: string, fallback: number, min: number, max: number): number;
@@ -0,0 +1,13 @@
1
+ export const pencilRenderDefaults = Object.freeze({
2
+ strokeWidth: 2,
3
+ strokeRoughness: 1.5,
4
+ washStrength: 0.8,
5
+ washSpacing: 2,
6
+ washRoughness: 3,
7
+ washAngle: -45,
8
+ });
9
+ export const maxStrokeRoughness = 4;
10
+ export function readRenderToken(style, name, fallback, min, max) {
11
+ const value = Number.parseFloat(style.getPropertyValue(name));
12
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
13
+ }
@@ -4,6 +4,14 @@ export interface PencilSurfaceTheme {
4
4
  dark: boolean;
5
5
  ink: string;
6
6
  wash: string;
7
+ strokeWidth?: number;
8
+ strokeRoughness?: number;
9
+ washSettings?: {
10
+ strength: number;
11
+ spacing: number;
12
+ roughness: number;
13
+ angle: number;
14
+ };
7
15
  }
8
16
  export declare const PENCIL_ACTION_WASH: Readonly<{
9
17
  lightStrength: 0.2;
@@ -14,9 +22,9 @@ export declare const PENCIL_QUIET_WASH: Readonly<{
14
22
  darkStrength: 0.08;
15
23
  }>;
16
24
  export declare const PENCIL_FILL_WASH: Readonly<{
17
- angle: -35;
18
- spacing: 1.8;
19
- roughness: 3.5;
25
+ angle: -45;
26
+ spacing: 2;
27
+ roughness: 3;
20
28
  inset: 2;
21
29
  edgeReach: 0;
22
30
  }>;
@@ -24,9 +32,9 @@ export declare const PENCIL_SELECTION_WASH: Readonly<{
24
32
  lightStrength: 0.2;
25
33
  darkStrength: 0.12;
26
34
  radius: 8;
27
- angle: -35;
28
- spacing: 1.8;
29
- roughness: 3.5;
35
+ angle: -45;
36
+ spacing: 2;
37
+ roughness: 3;
30
38
  inset: 2;
31
39
  edgeReach: 0;
32
40
  }>;
@@ -1,6 +1,7 @@
1
1
  import { pencilPaper } from './paper.js';
2
2
  import { pencilStroke } from './stroke.js';
3
3
  import { drawPencilWash } from './wash.js';
4
+ import { pencilRenderDefaults } from './renderTokens.js';
4
5
  export const PENCIL_ACTION_WASH = Object.freeze({
5
6
  lightStrength: 0.2,
6
7
  darkStrength: 0.12,
@@ -28,9 +29,9 @@ const surfaceStyles = {
28
29
  danger: { radius: 7, width: 0.75, passes: 2, strength: 0.18 },
29
30
  };
30
31
  export const PENCIL_FILL_WASH = Object.freeze({
31
- angle: -35,
32
- spacing: 1.8,
33
- roughness: 3.5,
32
+ angle: pencilRenderDefaults.washAngle,
33
+ spacing: pencilRenderDefaults.washSpacing,
34
+ roughness: pencilRenderDefaults.washRoughness,
34
35
  inset: 2,
35
36
  edgeReach: 0,
36
37
  });
@@ -85,9 +86,10 @@ export function drawPencilSurface(canvas, borderCanvas, width, height, variant,
85
86
  }
86
87
  context.translate(bleed, bleed);
87
88
  drawPencilWash(context, { x: 0, y: 0, width, height }, theme.wash, seed, {
88
- strength: 1,
89
- spacing: 1.8,
90
- roughness: 3.5,
89
+ strength: theme.washSettings?.strength ?? pencilRenderDefaults.washStrength / 0.48,
90
+ spacing: theme.washSettings?.spacing ?? pencilRenderDefaults.washSpacing,
91
+ roughness: theme.washSettings?.roughness ?? pencilRenderDefaults.washRoughness,
92
+ angle: (theme.washSettings?.angle ?? pencilRenderDefaults.washAngle) + 35,
91
93
  inset: 2,
92
94
  radius: 8,
93
95
  edgeReach: 8,
@@ -101,13 +103,13 @@ export function drawPencilSurface(canvas, borderCanvas, width, height, variant,
101
103
  borderCanvas.height = canvas.height;
102
104
  }
103
105
  drawPencilWash(context, { x: 0, y: 0, width, height }, theme.wash, seed, {
104
- strength: theme.dark ? PENCIL_SELECTION_WASH.darkStrength : PENCIL_SELECTION_WASH.lightStrength,
105
- spacing: PENCIL_SELECTION_WASH.spacing,
106
- roughness: PENCIL_SELECTION_WASH.roughness,
106
+ strength: (theme.dark ? PENCIL_SELECTION_WASH.darkStrength : PENCIL_SELECTION_WASH.lightStrength) * (theme.washSettings?.strength ?? pencilRenderDefaults.washStrength / 0.48),
107
+ spacing: theme.washSettings?.spacing ?? PENCIL_SELECTION_WASH.spacing,
108
+ roughness: theme.washSettings?.roughness ?? PENCIL_SELECTION_WASH.roughness,
107
109
  inset: PENCIL_SELECTION_WASH.inset,
108
110
  radius: PENCIL_SELECTION_WASH.radius,
109
111
  edgeReach: PENCIL_SELECTION_WASH.edgeReach,
110
- angle: PENCIL_SELECTION_WASH.angle,
112
+ angle: theme.washSettings?.angle ?? PENCIL_SELECTION_WASH.angle,
111
113
  dpr,
112
114
  });
113
115
  return undefined;
@@ -147,7 +149,8 @@ export function drawPencilSurface(canvas, borderCanvas, width, height, variant,
147
149
  if (strength) {
148
150
  drawPencilWash(context, { x: 0, y: 0, width, height }, theme.wash, seed, {
149
151
  ...PENCIL_FILL_WASH,
150
- strength,
152
+ ...theme.washSettings,
153
+ strength: strength * (theme.washSettings?.strength ?? pencilRenderDefaults.washStrength / 0.48),
151
154
  radius: resolvedRadius,
152
155
  dpr,
153
156
  });
@@ -164,7 +167,7 @@ export function drawPencilSurface(canvas, borderCanvas, width, height, variant,
164
167
  borderContext = separateContext;
165
168
  }
166
169
  }
167
- pencilStroke(borderContext, points, theme.ink, style.width, seed, 0.6, style.passes, true, {
170
+ pencilStroke(borderContext, points, theme.ink, style.width * ((theme.strokeWidth ?? pencilRenderDefaults.strokeWidth) / 1.2), seed, theme.strokeRoughness ?? pencilRenderDefaults.strokeRoughness, style.passes, true, {
168
171
  variation: 0.82,
169
172
  breaks: variant === 'paper' ? 0.2 : 0.25,
170
173
  grain: 0.7,
@@ -24,4 +24,5 @@ export declare function renderPencilText(text: string, options: {
24
24
  dpr: number;
25
25
  settings?: PencilLetteringSettings;
26
26
  cache?: boolean;
27
+ glyphSeed?: number;
27
28
  }): PencilTextSprite;
@@ -1,15 +1,16 @@
1
- import { BoundedCache, PENCIL_RENDERER_VERSION, canvasByteCost } from './cache.js';
1
+ import { BoundedCache, PENCIL_RENDERER_VERSION, canvasByteCost, canvasIsUsable } from './cache.js';
2
2
  import { seededRandom } from './random.js';
3
3
  export const pencilLettering = Object.freeze({
4
- variation: 0.52,
5
- breaks: 0.18,
6
- outline: 0.96,
7
- fill: 0.34,
8
- grain: 0.45,
4
+ variation: 0.2,
5
+ breaks: 0.5,
6
+ outline: 0.9,
7
+ fill: 0.9,
8
+ grain: 0.6,
9
9
  });
10
10
  export const pencilHandwriting = '"Trip Map Hand","HanziPen SC","Hannotate SC","Kaiti SC","STKaiti",cursive';
11
11
  const TEXT_PIXEL_LIMIT = 524288;
12
12
  const textCache = new BoundedCache(160, 16 * 1024 * 1024);
13
+ const glyphCache = new BoundedCache(512, 8 * 1024 * 1024);
13
14
  const clamp = (value) => Math.max(0, Math.min(1, value));
14
15
  function hash(x, y, seed) {
15
16
  let value = Math.imul(x ^ seed, 374761393) ^ Math.imul(y, 668265263);
@@ -88,10 +89,11 @@ export function renderPencilText(text, options) {
88
89
  resolution,
89
90
  settings,
90
91
  fontReady,
92
+ options.glyphSeed,
91
93
  ]) : '';
92
94
  if (cache) {
93
95
  const cached = textCache.get(key);
94
- if (cached)
96
+ if (cached && canvasIsUsable(cached.ink))
95
97
  return cached;
96
98
  }
97
99
  const mask = document.createElement('canvas');
@@ -120,6 +122,30 @@ export function renderPencilText(text, options) {
120
122
  if (!context)
121
123
  throw new Error('Canvas 2D is unavailable');
122
124
  context.scale(resolution, resolution);
125
+ // Plain Latin labels can reuse bounded character sprites. Complex scripts and
126
+ // private editable text retain the original whole-line rendering path.
127
+ if (cache && options.glyphSeed !== undefined && /^[\x20-\x7e]+$/.test(text)) {
128
+ let left = 0;
129
+ characters.forEach((character, index) => {
130
+ if (character !== ' ') {
131
+ const characterSeed = (options.glyphSeed ^ Math.imul(character.charCodeAt(0), 16777619)
132
+ ^ Math.imul(index % 3, 374761393)) >>> 0;
133
+ const glyphKey = JSON.stringify([
134
+ PENCIL_RENDERER_VERSION, character, size, family, color, characterSeed,
135
+ resolution, settings, document.fonts?.check(font, character),
136
+ ]);
137
+ let glyph = glyphCache.get(glyphKey);
138
+ if (!glyph || !canvasIsUsable(glyph.ink)) {
139
+ glyph = renderPencilText(character, { ...options, seed: characterSeed, glyphSeed: undefined, cache: false });
140
+ glyphCache.set(glyphKey, glyph, canvasByteCost(glyph.ink));
141
+ }
142
+ context.drawImage(glyph.ink, left, 0, glyph.width, glyph.height);
143
+ }
144
+ left += widths[index];
145
+ });
146
+ const sprite = { ink: mask, width, height, advance, ascent, descent, padding };
147
+ return textCache.set(key, sprite, canvasByteCost(mask));
148
+ }
123
149
  context.font = font;
124
150
  context.fontKerning = 'none';
125
151
  context.fillStyle = color;
@@ -3,6 +3,7 @@ import { PENCIL_RENDERER_VERSION, effectiveDpr } from './cache.js';
3
3
  import { observeCanvasRecovery } from './recovery.js';
4
4
  import { seedToNumber } from './random.js';
5
5
  import { drawPencilSurface } from './surfaceRenderer.js';
6
+ import { maxStrokeRoughness, pencilRenderDefaults, readRenderToken } from './renderTokens.js';
6
7
  function token(style, name, fallback) {
7
8
  return style.getPropertyValue(name).trim() || fallback;
8
9
  }
@@ -32,6 +33,14 @@ export function usePencilSurface(rootRef, canvasRef, borderRef, variant, seed, c
32
33
  ? token(style, '--inkra-danger', '#873e36')
33
34
  : token(style, '--inkra-border-strong', '#778575');
34
35
  const dark = style.colorScheme.includes('dark');
36
+ const strokeWidth = readRenderToken(style, '--inkra-stroke-width', pencilRenderDefaults.strokeWidth, 0.3, 3);
37
+ const strokeRoughness = readRenderToken(style, '--inkra-stroke-amplitude', pencilRenderDefaults.strokeRoughness, 0, maxStrokeRoughness);
38
+ const washSettings = {
39
+ strength: readRenderToken(style, '--inkra-wash-strength', pencilRenderDefaults.washStrength, 0, 1) / 0.48,
40
+ spacing: readRenderToken(style, '--inkra-wash-spacing', pencilRenderDefaults.washSpacing, 1, 6),
41
+ roughness: readRenderToken(style, '--inkra-wash-roughness', pencilRenderDefaults.washRoughness, 0, 6),
42
+ angle: readRenderToken(style, '--inkra-wash-angle', pencilRenderDefaults.washAngle, -85, 85),
43
+ };
35
44
  const wash = variant === 'selection'
36
45
  ? token(style, '--inkra-selection-pigment', dark ? '#c6b873' : '#769566')
37
46
  : variant === 'danger'
@@ -55,11 +64,12 @@ export function usePencilSurface(rootRef, canvasRef, borderRef, variant, seed, c
55
64
  dark,
56
65
  clipContent,
57
66
  radius,
67
+ strokeWidth, strokeRoughness, washSettings,
58
68
  ]);
59
69
  if (paintKey === lastPaint && !canvas.getContext('2d')?.isContextLost?.())
60
70
  return;
61
71
  try {
62
- const points = drawPencilSurface(canvas, borderRef.current, width, height, variant, numericSeed, dpr, { paper, dark, ink, wash }, radius);
72
+ const points = drawPencilSurface(canvas, borderRef.current, width, height, variant, numericSeed, dpr, { paper, dark, ink, wash, strokeWidth, strokeRoughness, washSettings }, radius);
63
73
  if (clipContent && points) {
64
74
  root.style.setProperty('--inkra-pencil-clip', `polygon(${points.map(([x, y]) => `${x / width * 100}% ${y / height * 100}%`).join(',')})`);
65
75
  }
@@ -82,13 +92,11 @@ export function usePencilSurface(rootRef, canvasRef, borderRef, variant, seed, c
82
92
  const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
83
93
  resize?.observe(root);
84
94
  window.addEventListener('resize', schedule, { passive: true });
85
- const appearance = new MutationObserver(schedule);
86
- appearance.observe(document.documentElement, {
87
- attributes: true,
88
- subtree: true,
89
- attributeFilter: ['data-theme', 'data-inkra-theme'],
90
- });
91
95
  const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
96
+ const inheritedStyle = new MutationObserver(schedule);
97
+ for (let element = root; element; element = element.parentElement) {
98
+ inheritedStyle.observe(element, { attributes: true, attributeFilter: ['style', 'class', 'data-theme', 'data-inkra-theme'] });
99
+ }
92
100
  colorScheme.addEventListener('change', schedule);
93
101
  const stopRecovery = observeCanvasRecovery(() => {
94
102
  lastPaint = '';
@@ -99,7 +107,7 @@ export function usePencilSurface(rootRef, canvasRef, borderRef, variant, seed, c
99
107
  disposed = true;
100
108
  stopRecovery();
101
109
  resize?.disconnect();
102
- appearance.disconnect();
110
+ inheritedStyle.disconnect();
103
111
  colorScheme.removeEventListener('change', schedule);
104
112
  window.removeEventListener('resize', schedule);
105
113
  cancelAnimationFrame(frame);
@@ -1,9 +1,10 @@
1
1
  import { useRef } from 'react';
2
2
  import { useBrowserLayoutEffect } from './useBrowserLayoutEffect.js';
3
3
  import { PENCIL_SELECTION_WASH } from './surfaceRenderer.js';
4
+ import { readRenderToken } from './renderTokens.js';
4
5
  // Reveal the cached pigment in alternating diagonal strokes, without repainting it.
5
- function drawingFrames(width, height) {
6
- const angle = PENCIL_SELECTION_WASH.angle * Math.PI / 180;
6
+ function drawingFrames(width, height, degrees) {
7
+ const angle = degrees * Math.PI / 180;
7
8
  const cos = Math.cos(angle);
8
9
  const sin = Math.sin(angle);
9
10
  const u = (Math.abs(cos) * width + Math.abs(sin) * height) / 2 + 2;
@@ -16,14 +17,16 @@ function drawingFrames(width, height) {
16
17
  const bottom = -v + (index + 1) * 2 * v / strokes;
17
18
  // Limit travel to the visible section of this stripe, not its rotated bounds.
18
19
  const middle = (top + bottom) / 2;
19
- const ends = [
20
- (-width / 2 + sin * middle) / cos,
21
- (width / 2 + sin * middle) / cos,
22
- (-height / 2 - cos * middle) / sin,
23
- (height / 2 - cos * middle) / sin,
24
- ];
25
- const left = Math.max(Math.min(ends[0], ends[1]), Math.min(ends[2], ends[3]));
26
- const right = Math.min(Math.max(ends[0], ends[1]), Math.max(ends[2], ends[3]));
20
+ let left = -u;
21
+ let right = u;
22
+ for (const [direction, offset, halfExtent] of [[cos, -sin * middle, width / 2], [sin, cos * middle, height / 2]]) {
23
+ if (Math.abs(direction) < 1e-8)
24
+ continue;
25
+ const a = (-halfExtent - offset) / direction;
26
+ const b = (halfExtent - offset) / direction;
27
+ left = Math.max(left, Math.min(a, b));
28
+ right = Math.min(right, Math.max(a, b));
29
+ }
27
30
  for (const progress of [0, 0.5, 1]) {
28
31
  const x = progress === 0 ? (index % 2 ? u : -u) : progress === 1 ? (index % 2 ? -u : u) : (left + right) / 2;
29
32
  const outline = index % 2
@@ -54,7 +57,8 @@ export function useSelectionDrawing(ref, selected, animated = true) {
54
57
  return;
55
58
  }
56
59
  const duration = selected ? 480 : 320;
57
- const animation = node.animate(drawingFrames(node.clientWidth, node.clientHeight).map(frame => ({ ...frame, opacity: 1 })), { duration, direction: selected ? 'normal' : 'reverse', easing: 'linear' });
60
+ const angle = readRenderToken(getComputedStyle(node), '--inkra-wash-angle', PENCIL_SELECTION_WASH.angle, -85, 85);
61
+ const animation = node.animate(drawingFrames(node.clientWidth, node.clientHeight, angle).map(frame => ({ ...frame, opacity: 1 })), { duration, direction: selected ? 'normal' : 'reverse', easing: 'linear' });
58
62
  animation.currentTime = (selected ? progress.current : 1 - progress.current) * duration;
59
63
  let settled = false;
60
64
  const finish = () => {
@@ -43,7 +43,9 @@ export function MapScale({ map }) {
43
43
  draw();
44
44
  map.on('moveend zoomend resize', draw);
45
45
  const observer = new MutationObserver(draw);
46
- observer.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ['data-theme', 'data-inkra-theme'] });
46
+ for (let element = bar.current; element; element = element.parentElement) {
47
+ observer.observe(element, { attributes: true, attributeFilter: ['data-theme', 'data-inkra-theme'] });
48
+ }
47
49
  const scheme = matchMedia('(prefers-color-scheme: dark)');
48
50
  scheme.addEventListener('change', draw);
49
51
  return () => { map.off('moveend zoomend resize', draw); observer.disconnect(); scheme.removeEventListener('change', draw); };
@@ -148,7 +148,9 @@ export function JourneyMap({ places, routes = noRoutes, geography, landCover, wa
148
148
  fit(); schedule(); });
149
149
  observer.observe(host.current);
150
150
  const appearance = new MutationObserver(schedule);
151
- appearance.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ['data-theme', 'data-inkra-theme'] });
151
+ for (let element = host.current; element; element = element.parentElement) {
152
+ appearance.observe(element, { attributes: true, attributeFilter: ['data-theme', 'data-inkra-theme'] });
153
+ }
152
154
  const scheme = matchMedia('(prefers-color-scheme: dark)');
153
155
  scheme.addEventListener('change', schedule);
154
156
  fit();