@inkra/react 0.8.0 → 0.10.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 (76) hide show
  1. package/README.md +137 -2
  2. package/THIRD_PARTY_NOTICES.md +9 -0
  3. package/Xiaolai-OFL.txt +94 -0
  4. package/dist/PencilIcon.d.ts +14 -1
  5. package/dist/PencilIcon.js +88 -52
  6. package/dist/PencilText.js +4 -1
  7. package/dist/assets/ui-hand-xiaolai-extended.woff2 +0 -0
  8. package/dist/assets/ui-hand-xiaolai.woff2 +0 -0
  9. package/dist/assets/xiaolai.css +17 -0
  10. package/dist/form/EditablePencilText.js +4 -2
  11. package/dist/guide/index.d.ts +77 -0
  12. package/dist/guide/index.js +379 -0
  13. package/dist/i18n/catalog.d.ts +1 -1
  14. package/dist/i18n/catalog.js +8 -0
  15. package/dist/image-viewer/index.d.ts +2 -0
  16. package/dist/image-viewer/index.js +28 -28
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +4 -0
  19. package/dist/infinite-scroll/gesture.d.ts +19 -0
  20. package/dist/infinite-scroll/gesture.js +218 -0
  21. package/dist/infinite-scroll/index.d.ts +55 -0
  22. package/dist/infinite-scroll/index.js +543 -0
  23. package/dist/infinite-scroll/model.d.ts +33 -0
  24. package/dist/infinite-scroll/model.js +168 -0
  25. package/dist/internal/iconFill.d.ts +16 -0
  26. package/dist/internal/iconFill.js +275 -0
  27. package/dist/internal/surfaceRenderer.d.ts +4 -4
  28. package/dist/internal/surfaceRenderer.js +2 -2
  29. package/dist/internal/textRenderer.d.ts +1 -1
  30. package/dist/internal/textRenderer.js +23 -11
  31. package/dist/inventory-grid/index.js +13 -3
  32. package/dist/inventory-grid/outline.d.ts +9 -0
  33. package/dist/inventory-grid/outline.js +54 -0
  34. package/dist/loading/index.d.ts +58 -0
  35. package/dist/loading/index.js +188 -0
  36. package/dist/loading/locales.d.ts +15 -0
  37. package/dist/loading/locales.js +34 -0
  38. package/dist/loading/model.d.ts +6 -0
  39. package/dist/loading/model.js +29 -0
  40. package/dist/masonry/index.d.ts +25 -0
  41. package/dist/masonry/index.js +227 -0
  42. package/dist/masonry/layout.d.ts +35 -0
  43. package/dist/masonry/layout.js +67 -0
  44. package/dist/number-ticker/RollingValue.d.ts +6 -0
  45. package/dist/number-ticker/RollingValue.js +10 -0
  46. package/dist/number-ticker/index.d.ts +4 -0
  47. package/dist/number-ticker/index.js +55 -7
  48. package/dist/number-ticker/rolling.d.ts +35 -0
  49. package/dist/number-ticker/rolling.js +87 -0
  50. package/dist/selection/CircularSwitchVisual.d.ts +8 -0
  51. package/dist/selection/CircularSwitchVisual.js +82 -0
  52. package/dist/selection/Switch.d.ts +7 -0
  53. package/dist/selection/Switch.js +15 -3
  54. package/dist/slider/circular.d.ts +35 -0
  55. package/dist/slider/circular.js +196 -0
  56. package/dist/slider/circularMath.d.ts +10 -0
  57. package/dist/slider/circularMath.js +34 -0
  58. package/dist/slider/index.d.ts +5 -0
  59. package/dist/slider/index.js +5 -3
  60. package/dist/styles/base.css +4 -2
  61. package/dist/styles/breadcrumb.css +1 -1
  62. package/dist/styles/guide.css +202 -0
  63. package/dist/styles/image-viewer.css +20 -0
  64. package/dist/styles/infinite-scroll.css +171 -0
  65. package/dist/styles/inventory-grid.css +20 -8
  66. package/dist/styles/loading.css +348 -0
  67. package/dist/styles/markdown.css +1 -1
  68. package/dist/styles/masonry.css +68 -0
  69. package/dist/styles/number-ticker.css +47 -1
  70. package/dist/styles/selection.css +50 -0
  71. package/dist/styles/slider.css +65 -0
  72. package/dist/styles/slot.css +1 -1
  73. package/dist/styles.css +1180 -192
  74. package/dist/toast/manager.d.ts +4 -4
  75. package/dist/tokens.css +13 -10
  76. package/package.json +28 -2
@@ -0,0 +1,168 @@
1
+ export const infiniteScrollDirections = ['top', 'bottom', 'left', 'right'];
2
+ export function completeDirectionalRecord(value, fallback) {
3
+ return {
4
+ top: value?.top ?? fallback('top'),
5
+ bottom: value?.bottom ?? fallback('bottom'),
6
+ left: value?.left ?? fallback('left'),
7
+ right: value?.right ?? fallback('right'),
8
+ };
9
+ }
10
+ export function normalizeDirections(value) {
11
+ const requested = value === undefined ? ['bottom'] : Array.isArray(value) ? value : [value];
12
+ return infiniteScrollDirections.filter(direction => requested.includes(direction));
13
+ }
14
+ export function directionAxis(direction) {
15
+ return direction === 'top' || direction === 'bottom' ? 'vertical' : 'horizontal';
16
+ }
17
+ export function directionSign(direction) {
18
+ return direction === 'top' || direction === 'left' ? 1 : -1;
19
+ }
20
+ export function directionalValue(value, direction, fallback) {
21
+ if (value === undefined)
22
+ return fallback;
23
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
24
+ return value[direction] ?? fallback;
25
+ }
26
+ return value;
27
+ }
28
+ export function finiteDistance(value, fallback, minimum = 0) {
29
+ return typeof value === 'number' && Number.isFinite(value)
30
+ ? Math.max(minimum, value)
31
+ : fallback;
32
+ }
33
+ const rtlScrollTypes = new WeakMap();
34
+ export function boundaryDistance(metrics, direction) {
35
+ if (direction === 'top')
36
+ return Math.max(0, metrics.scrollTop);
37
+ if (direction === 'bottom') {
38
+ return Math.max(0, metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop);
39
+ }
40
+ // Horizontal browser scroll coordinates differ in RTL. Physical edge
41
+ // detection is performed from sentinels in the component; these values are
42
+ // the LTR/non-negative fallback used when geometry is unavailable.
43
+ if (direction === 'left')
44
+ return Math.max(0, metrics.scrollLeft);
45
+ return Math.max(0, metrics.scrollWidth - metrics.clientWidth - metrics.scrollLeft);
46
+ }
47
+ export function physicalHorizontalBoundaryDistance(metrics, direction, rtl = false, rtlType = 'negative') {
48
+ const maximum = Math.max(0, metrics.scrollWidth - metrics.clientWidth);
49
+ const fromLeft = !rtl ? metrics.scrollLeft
50
+ : rtlType === 'negative' ? maximum + metrics.scrollLeft
51
+ : rtlType === 'reverse' ? maximum - metrics.scrollLeft
52
+ : metrics.scrollLeft;
53
+ const normalized = Math.max(0, Math.min(maximum, fromLeft));
54
+ return direction === 'left' ? normalized : maximum - normalized;
55
+ }
56
+ function detectRtlScrollType(document) {
57
+ const cached = rtlScrollTypes.get(document);
58
+ if (cached)
59
+ return cached;
60
+ if (!document.body || typeof document.createElement !== 'function')
61
+ return 'negative';
62
+ const outer = document.createElement('div');
63
+ const inner = document.createElement('div');
64
+ Object.assign(outer.style, {
65
+ direction: 'rtl',
66
+ height: '1px',
67
+ overflow: 'scroll',
68
+ position: 'absolute',
69
+ top: '-1000px',
70
+ width: '4px',
71
+ });
72
+ Object.assign(inner.style, { height: '1px', width: '8px' });
73
+ outer.append(inner);
74
+ document.body.append(outer);
75
+ let type;
76
+ if (outer.scrollLeft > 0)
77
+ type = 'default';
78
+ else {
79
+ outer.scrollLeft = 1;
80
+ type = outer.scrollLeft === 0 ? 'negative' : 'reverse';
81
+ }
82
+ outer.remove();
83
+ rtlScrollTypes.set(document, type);
84
+ return type;
85
+ }
86
+ function scrollBoundaryDistance(container, direction) {
87
+ if (direction === 'top' || direction === 'bottom')
88
+ return boundaryDistance(container, direction);
89
+ const rtl = container.ownerDocument.defaultView?.getComputedStyle(container).direction === 'rtl';
90
+ return physicalHorizontalBoundaryDistance(container, direction, rtl, rtl ? detectRtlScrollType(container.ownerDocument) : 'negative');
91
+ }
92
+ export function dampPullDistance(distance, threshold) {
93
+ const safeDistance = finiteDistance(distance, 0);
94
+ const limit = finiteDistance(threshold, 72, 1) * 2;
95
+ // Continuous resistance, bounded to twice the trigger distance even on a long pull.
96
+ return limit * (safeDistance / (limit + safeDistance));
97
+ }
98
+ export function pullProgress(distance, threshold) {
99
+ return Math.max(0, Math.min(1, distance / Math.max(1, threshold)));
100
+ }
101
+ export function gestureStatusEnabled(status) {
102
+ return status === 'idle' || status === 'pulling' || status === 'ready';
103
+ }
104
+ export function indicatorVisible(status, automatic, gesture = false) {
105
+ return status !== 'idle' || (!automatic && !gesture);
106
+ }
107
+ export function statusAfterCancellation(status) {
108
+ return status === 'loading' || status === 'pulling' || status === 'ready' ? 'idle' : status;
109
+ }
110
+ export function localScrollDelta(container, direction, physicalDelta) {
111
+ const rect = container.getBoundingClientRect();
112
+ const renderedSize = direction === 'top' ? rect.height : rect.width;
113
+ const localSize = direction === 'top' ? container.clientHeight : container.clientWidth;
114
+ return renderedSize > 0 && localSize > 0 ? physicalDelta * localSize / renderedSize : physicalDelta;
115
+ }
116
+ export function boundarySignature(container, root) {
117
+ return [
118
+ container.scrollWidth,
119
+ container.scrollHeight,
120
+ container.clientWidth,
121
+ container.clientHeight,
122
+ root.childElementCount,
123
+ root.querySelectorAll('[data-infinite-scroll-anchor]').length,
124
+ ].join(':');
125
+ }
126
+ export function isPageScrollContainer(container) {
127
+ const doc = container.ownerDocument;
128
+ return container === doc.documentElement || container === doc.body || container === doc.scrollingElement;
129
+ }
130
+ export function physicalBoundaryDistance(container, sentinel, direction, root) {
131
+ if (container === root || !sentinel)
132
+ return scrollBoundaryDistance(container, direction);
133
+ const viewport = container.getBoundingClientRect();
134
+ const edge = sentinel.getBoundingClientRect();
135
+ const scaleX = viewport.width > 0 && container.offsetWidth > 0 ? viewport.width / container.offsetWidth : 1;
136
+ const scaleY = viewport.height > 0 && container.offsetHeight > 0 ? viewport.height / container.offsetHeight : 1;
137
+ const top = viewport.top + container.clientTop * scaleY;
138
+ const left = viewport.left + container.clientLeft * scaleX;
139
+ const bottom = top + container.clientHeight * scaleY;
140
+ const right = left + container.clientWidth * scaleX;
141
+ if (direction === 'top')
142
+ return Math.abs(top - edge.top) / scaleY;
143
+ if (direction === 'bottom')
144
+ return Math.abs(edge.bottom - bottom) / scaleY;
145
+ if (direction === 'left')
146
+ return Math.abs(left - edge.left) / scaleX;
147
+ return Math.abs(edge.right - right) / scaleX;
148
+ }
149
+ export function isEditableOrInteractiveTarget(target) {
150
+ return Boolean(target?.closest('input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"]), '
151
+ + '[role="slider"], [role="spinbutton"], [role="combobox"], [role="listbox"], '
152
+ + 'video, audio, [data-infinite-scroll-gesture-ignore]'));
153
+ }
154
+ export function hasNestedScrollOwner(target, root, container, axis) {
155
+ const view = root.ownerDocument.defaultView;
156
+ if (!view)
157
+ return false;
158
+ for (let node = target; node && node !== root && node !== container; node = node.parentElement) {
159
+ const style = view.getComputedStyle(node);
160
+ if (axis === 'vertical') {
161
+ if (node.scrollHeight > node.clientHeight + 1 && /auto|scroll|overlay/.test(style.overflowY))
162
+ return true;
163
+ }
164
+ else if (node.scrollWidth > node.clientWidth + 1 && /auto|scroll|overlay/.test(style.overflowX))
165
+ return true;
166
+ }
167
+ return false;
168
+ }
@@ -0,0 +1,16 @@
1
+ import type { PencilIconPath } from '../PencilIcon.js';
2
+ import { type PencilPoint } from './stroke.js';
3
+ export declare function isClosedIconContour(points: readonly PencilPoint[]): boolean;
4
+ export declare function softenIconFillMask(pixels: Uint8ClampedArray, width: number, height: number, scale: number, seed: number, roughness: number): void;
5
+ export declare function paintFilledIcon(target: CanvasRenderingContext2D, paths: readonly PencilIconPath[], options: {
6
+ color: string;
7
+ fillColor: string;
8
+ sourceSize: number;
9
+ seed: number;
10
+ weightScale: number;
11
+ roughnessScale: number;
12
+ breaks: number;
13
+ breakCount: number;
14
+ pressureVariation: number;
15
+ coordinateScale: number;
16
+ }): void;
@@ -0,0 +1,275 @@
1
+ import { cachedPathParts } from './iconGeometry.js';
2
+ import { pencilStroke } from './stroke.js';
3
+ export function isClosedIconContour(points) {
4
+ if (points.length < 4)
5
+ return false;
6
+ const first = points[0];
7
+ const last = points[points.length - 1];
8
+ if (Math.hypot(first[0] - last[0], first[1] - last[1]) > 0.3)
9
+ return false;
10
+ let area = 0;
11
+ for (let i = 1; i < points.length; i++) {
12
+ area += points[i - 1][0] * points[i][1] - points[i][0] * points[i - 1][1];
13
+ }
14
+ return Math.abs(area) > 0.1;
15
+ }
16
+ function contourPath(points) {
17
+ const path = new Path2D();
18
+ if (!points.length)
19
+ return path;
20
+ path.moveTo(points[0][0], points[0][1]);
21
+ for (const [x, y] of points.slice(1))
22
+ path.lineTo(x, y);
23
+ return path;
24
+ }
25
+ export function softenIconFillMask(pixels, width, height, scale, seed, roughness) {
26
+ const distance = new Float32Array(width * height);
27
+ const feather = Math.max(1.5, scale * 0.8);
28
+ for (let i = 0; i < distance.length; i++) {
29
+ const alpha = pixels[i * 4 + 3];
30
+ distance[i] = alpha === 255 ? feather : alpha / 255;
31
+ }
32
+ // Distance into the silhouette, including holes, without spreading pigment outside it.
33
+ for (let y = 0; y < height; y++)
34
+ for (let x = 0; x < width; x++) {
35
+ const i = y * width + x;
36
+ if (x)
37
+ distance[i] = Math.min(distance[i], distance[i - 1] + 1);
38
+ if (y)
39
+ distance[i] = Math.min(distance[i], distance[i - width] + 1);
40
+ if (x && y)
41
+ distance[i] = Math.min(distance[i], distance[i - width - 1] + Math.SQRT2);
42
+ if (x + 1 < width && y)
43
+ distance[i] = Math.min(distance[i], distance[i - width + 1] + Math.SQRT2);
44
+ }
45
+ for (let y = height - 1; y >= 0; y--)
46
+ for (let x = width - 1; x >= 0; x--) {
47
+ const i = y * width + x;
48
+ if (x + 1 < width)
49
+ distance[i] = Math.min(distance[i], distance[i + 1] + 1);
50
+ if (y + 1 < height)
51
+ distance[i] = Math.min(distance[i], distance[i + width] + 1);
52
+ if (x && y + 1 < height)
53
+ distance[i] = Math.min(distance[i], distance[i + width - 1] + Math.SQRT2);
54
+ if (x + 1 < width && y + 1 < height)
55
+ distance[i] = Math.min(distance[i], distance[i + width + 1] + Math.SQRT2);
56
+ }
57
+ const grain = Math.min(1, Math.max(0, roughness));
58
+ for (let y = 0; y < height; y++)
59
+ for (let x = 0; x < width; x++) {
60
+ const i = y * width + x;
61
+ if (!pixels[i * 4 + 3] || distance[i] >= feather)
62
+ continue;
63
+ const t = distance[i] / feather;
64
+ const edge = 1 - t * t * (3 - 2 * t);
65
+ let hash = Math.imul(Math.floor(x / scale * 3) + seed, 374761393)
66
+ ^ Math.imul(Math.floor(y / scale * 3), 668265263);
67
+ hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
68
+ const tooth = ((hash ^ (hash >>> 16)) >>> 0) / 4294967295;
69
+ // Keep at least half the existing coverage so fine details remain legible.
70
+ pixels[i * 4 + 3] *= 1 - edge * (0.35 + tooth * 0.15 * grain);
71
+ }
72
+ }
73
+ export function paintFilledIcon(target, paths, options) {
74
+ const { color, fillColor, seed, weightScale, roughnessScale } = options;
75
+ const layer = () => {
76
+ const canvas = document.createElement('canvas');
77
+ canvas.width = target.canvas.width;
78
+ canvas.height = target.canvas.height;
79
+ const context = canvas.getContext('2d');
80
+ context.setTransform(target.getTransform());
81
+ return { canvas, context };
82
+ };
83
+ const body = layer();
84
+ const outline = layer();
85
+ const washOutline = paths.some(path => path.mode === 'wash') ? layer() : null;
86
+ const texture = layer();
87
+ const automatic = [];
88
+ const details = new Path2D();
89
+ const fills = [];
90
+ const cutouts = [];
91
+ let samples = 0;
92
+ paths.forEach((path, index) => {
93
+ if (path.d.length > 65536)
94
+ return;
95
+ if (path.mode === 'fill' || path.mode === 'wash' || path.mode === 'cutout') {
96
+ const shape = { path: new Path2D(path.d), rule: path.fillRule ?? 'nonzero' };
97
+ if (path.mode === 'cutout') {
98
+ const closedParts = new Path2D();
99
+ let closedCount = 0;
100
+ const openParts = new Path2D();
101
+ for (const part of cachedPathParts(path.d)) {
102
+ samples += part.length;
103
+ if (samples > 65536)
104
+ throw new RangeError('Pencil icon exceeds the bounded sampling budget');
105
+ if (isClosedIconContour(part)) {
106
+ closedParts.addPath(contourPath(part));
107
+ closedCount++;
108
+ }
109
+ else
110
+ openParts.addPath(contourPath(part));
111
+ }
112
+ // Native stroking preserves tiny disjoint dots that distance sampling can skip.
113
+ details.addPath(closedCount ? openParts : shape.path);
114
+ cutouts.push({ path: closedParts, rule: shape.rule });
115
+ }
116
+ else {
117
+ let narrowest = Infinity;
118
+ if (washOutline) {
119
+ // A front region covers earlier edges, but retains its own complete stroke.
120
+ washOutline.context.globalCompositeOperation = 'destination-out';
121
+ washOutline.context.fill(shape.path, shape.rule);
122
+ washOutline.context.globalCompositeOperation = 'source-over';
123
+ }
124
+ if (path.mode === 'wash')
125
+ for (const points of cachedPathParts(path.d)) {
126
+ samples += points.length;
127
+ if (samples > 65536)
128
+ throw new RangeError('Pencil icon exceeds the bounded sampling budget');
129
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
130
+ for (const [x, y] of points) {
131
+ left = Math.min(left, x);
132
+ top = Math.min(top, y);
133
+ right = Math.max(right, x);
134
+ bottom = Math.max(bottom, y);
135
+ }
136
+ const breadth = Math.max(0, Math.min(right - left, bottom - top)) * options.coordinateScale;
137
+ narrowest = Math.min(narrowest, breadth);
138
+ // Subpixel color accents need lighter edges, not a full-size ink border.
139
+ const edgeWeight = Math.min(1, breadth / 2);
140
+ pencilStroke(washOutline.context, points, color, 0.65 * weightScale * edgeWeight, seed + index * 137, 0.3 * roughnessScale, 2, false, { variation: options.pressureVariation, fitPressure: true, breaks: options.breaks,
141
+ breakCount: options.breakCount, breakScale: options.coordinateScale, continuousBreaks: true,
142
+ grain: 0.68, gain: 1.5, step: 0.35, taperLength: 0.65 });
143
+ }
144
+ fills.push({ ...shape, color: path.color, wash: path.mode === 'wash', narrowest });
145
+ }
146
+ return;
147
+ }
148
+ const parts = cachedPathParts(path.d);
149
+ samples += parts.reduce((total, part) => total + part.length, 0);
150
+ if (samples > 65536)
151
+ throw new RangeError('Pencil icon exceeds the bounded sampling budget');
152
+ for (const points of parts) {
153
+ const closed = path.mode !== 'stroke' && isClosedIconContour(points);
154
+ const shape = contourPath(points);
155
+ if (closed) {
156
+ shape.closePath();
157
+ const bounds = [Infinity, Infinity, -Infinity, -Infinity];
158
+ for (const [x, y] of points) {
159
+ bounds[0] = Math.min(bounds[0], x);
160
+ bounds[1] = Math.min(bounds[1], y);
161
+ bounds[2] = Math.max(bounds[2], x);
162
+ bounds[3] = Math.max(bounds[3], y);
163
+ }
164
+ automatic.push({ path: shape, points, bounds, depth: 0 });
165
+ }
166
+ else
167
+ details.addPath(shape);
168
+ pencilStroke(outline.context, points, color, (closed ? 1.65 : 2.4) * weightScale, seed + index * 137, 0.2 * roughnessScale, 3, false, { variation: options.pressureVariation, fitPressure: true, breaks: options.breaks,
169
+ breakCount: options.breakCount, breakScale: options.coordinateScale, continuousBreaks: true,
170
+ grain: 0.68, gain: 3, step: 0.35 });
171
+ if (!closed)
172
+ pencilStroke(outline.context, points, fillColor, 1.5 * weightScale, seed + index * 137, 0.15 * roughnessScale, 3, false, { variation: 0.25, fitPressure: true, breaks: 0, grain: 0.68, gain: 3, step: 0.35 });
173
+ }
174
+ if (parts.every(points => !isClosedIconContour(points)))
175
+ details.addPath(new Path2D(path.d));
176
+ });
177
+ // Only containment creates a hole. Overlapping independent silhouettes are a union.
178
+ const area = (bounds) => (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]);
179
+ automatic.sort((a, b) => area(b.bounds) - area(a.bounds));
180
+ let containmentChecks = 0;
181
+ texture.context.save();
182
+ texture.context.resetTransform();
183
+ for (let index = 0; index < automatic.length; index++) {
184
+ const shape = automatic[index];
185
+ for (let parentIndex = index - 1; parentIndex >= 0; parentIndex--) {
186
+ const parent = automatic[parentIndex];
187
+ const a = shape.bounds, b = parent.bounds;
188
+ if (area(a) >= area(b) || a[0] < b[0] || a[1] < b[1] || a[2] > b[2] || a[3] > b[3])
189
+ continue;
190
+ const inside = shape.points.every(([x, y]) => {
191
+ if (++containmentChecks > 131072)
192
+ throw new RangeError('Pencil icon exceeds the bounded containment budget');
193
+ return texture.context.isPointInPath(parent.path, x, y, 'evenodd');
194
+ });
195
+ if (inside) {
196
+ shape.depth = parent.depth + 1;
197
+ break;
198
+ }
199
+ }
200
+ body.context.globalCompositeOperation = shape.depth % 2 ? 'destination-out' : 'source-over';
201
+ body.context.fill(shape.path, 'evenodd');
202
+ }
203
+ texture.context.restore();
204
+ body.context.globalCompositeOperation = 'source-over';
205
+ for (const fill of fills)
206
+ body.context.fill(fill.path, fill.rule);
207
+ // Keep the exterior contour, but do not paint positive strokes over interior details.
208
+ outline.context.save();
209
+ outline.context.resetTransform();
210
+ outline.context.globalCompositeOperation = 'destination-out';
211
+ outline.context.drawImage(body.canvas, 0, 0);
212
+ outline.context.restore();
213
+ outline.context.globalCompositeOperation = 'destination-out';
214
+ for (const shape of automatic) {
215
+ if (shape.depth % 2)
216
+ outline.context.fill(shape.path, 'evenodd');
217
+ }
218
+ body.context.globalCompositeOperation = 'destination-out';
219
+ body.context.lineWidth = 1.5 * weightScale;
220
+ body.context.lineCap = 'round';
221
+ body.context.lineJoin = 'round';
222
+ body.context.stroke(details);
223
+ for (const shape of automatic) {
224
+ if (shape.depth % 2 && Math.min(shape.bounds[2] - shape.bounds[0], shape.bounds[3] - shape.bounds[1]) < 1.5) {
225
+ // Tiny negative dots must survive the normal 24-40px icon sizes.
226
+ body.context.lineWidth = 0.8;
227
+ body.context.stroke(shape.path);
228
+ }
229
+ }
230
+ for (const cutout of cutouts)
231
+ body.context.fill(cutout.path, cutout.rule);
232
+ if (washOutline) {
233
+ washOutline.context.globalCompositeOperation = 'destination-out';
234
+ washOutline.context.lineWidth = 1.5 * weightScale;
235
+ washOutline.context.lineCap = 'round';
236
+ washOutline.context.lineJoin = 'round';
237
+ washOutline.context.stroke(details);
238
+ for (const cutout of cutouts)
239
+ washOutline.context.fill(cutout.path, cutout.rule);
240
+ }
241
+ const mask = body.context.getImageData(0, 0, body.canvas.width, body.canvas.height);
242
+ softenIconFillMask(mask.data, mask.width, mask.height, target.getTransform().a, seed, roughnessScale);
243
+ body.context.putImageData(mask, 0, 0);
244
+ const paint = (pigment, wash = false, narrowest = Infinity) => {
245
+ // Wash leaves paper between strokes; solid fill retains its established density.
246
+ texture.context.fillStyle = pigment;
247
+ texture.context.globalAlpha = wash ? 0.32 + 0.2 * Math.max(0, 1 - narrowest / 2) : 0.65;
248
+ const start = (options.sourceSize - 40) / 2;
249
+ texture.context.fillRect(start, start, 40, 40);
250
+ texture.context.globalAlpha = 1;
251
+ for (let y = start - 10; y < start + 50; y += wash ? 1.1 : 0.8) {
252
+ pencilStroke(texture.context, [[start - 2, y + 6], [start + 42, y - 6]], pigment, wash ? 0.8 : 0.9, seed + Math.round(y * 97), (wash ? 0.2 : 0.15) * roughnessScale, 1, false, { variation: 0.3, fitPressure: true, breaks: 0, grain: 0.7, gain: wash ? 0.75 : 1.5, step: 0.5 });
253
+ }
254
+ };
255
+ paint(fillColor);
256
+ for (const fill of fills) {
257
+ if (!fill.color && !fill.wash)
258
+ continue;
259
+ texture.context.save();
260
+ texture.context.clip(fill.path, fill.rule);
261
+ texture.context.clearRect(-4096, -4096, 8192, 8192);
262
+ paint(fill.color ?? fillColor, fill.wash, fill.narrowest);
263
+ texture.context.restore();
264
+ }
265
+ texture.context.resetTransform();
266
+ texture.context.globalCompositeOperation = 'destination-in';
267
+ texture.context.drawImage(body.canvas, 0, 0);
268
+ target.save();
269
+ target.resetTransform();
270
+ target.drawImage(texture.canvas, 0, 0);
271
+ target.drawImage(outline.canvas, 0, 0);
272
+ if (washOutline)
273
+ target.drawImage(washOutline.canvas, 0, 0);
274
+ target.restore();
275
+ }
@@ -14,8 +14,8 @@ export interface PencilSurfaceTheme {
14
14
  };
15
15
  }
16
16
  export declare const PENCIL_ACTION_WASH: Readonly<{
17
- lightStrength: 0.2;
18
- darkStrength: 0.12;
17
+ lightStrength: 0.15;
18
+ darkStrength: 0.09;
19
19
  }>;
20
20
  export declare const PENCIL_QUIET_WASH: Readonly<{
21
21
  lightStrength: 0.12;
@@ -29,8 +29,8 @@ export declare const PENCIL_FILL_WASH: Readonly<{
29
29
  edgeReach: 0;
30
30
  }>;
31
31
  export declare const PENCIL_SELECTION_WASH: Readonly<{
32
- lightStrength: 0.2;
33
- darkStrength: 0.12;
32
+ lightStrength: 0.15;
33
+ darkStrength: 0.09;
34
34
  radius: 8;
35
35
  angle: -45;
36
36
  spacing: 2;
@@ -5,8 +5,8 @@ import { pencilRenderDefaults } from './renderTokens.js';
5
5
  import { BoundedCache, PENCIL_RENDERER_VERSION, canvasByteCost, canvasIsUsable } from './cache.js';
6
6
  import { releaseSurfaceBitmaps, resizeSurfaceBitmap, resolveSurfaceAllocation } from './surfaceBudget.js';
7
7
  export const PENCIL_ACTION_WASH = Object.freeze({
8
- lightStrength: 0.2,
9
- darkStrength: 0.12,
8
+ lightStrength: 0.15,
9
+ darkStrength: 0.09,
10
10
  });
11
11
  export const PENCIL_QUIET_WASH = Object.freeze({
12
12
  lightStrength: 0.12,
@@ -15,7 +15,7 @@ export interface PencilTextSprite {
15
15
  padding: number;
16
16
  }
17
17
  export declare const pencilLettering: PencilLetteringSettings;
18
- export declare const pencilHandwriting = "\"Trip Map Hand\",\"HanziPen SC\",\"Hannotate SC\",\"Kaiti SC\",\"STKaiti\",cursive";
18
+ export declare const pencilHandwriting = "\"Inkra Xiaolai\",\"Trip Map Hand\",\"Kaiti SC\",\"STKaiti\",cursive";
19
19
  export declare function boundedTextResolution(width: number, height: number, dpr: number): number;
20
20
  export declare function renderPencilText(text: string, options: {
21
21
  size: number;
@@ -1,13 +1,13 @@
1
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.2,
5
- breaks: 0.5,
6
- outline: 0.9,
7
- fill: 0.9,
8
- grain: 0.6,
4
+ variation: 0.1,
5
+ breaks: 0.31,
6
+ outline: 0.44,
7
+ fill: 0.85,
8
+ grain: 0.4,
9
9
  });
10
- export const pencilHandwriting = '"Trip Map Hand","HanziPen SC","Hannotate SC","Kaiti SC","STKaiti",cursive';
10
+ export const pencilHandwriting = '"Inkra Xiaolai","Trip Map Hand","Kaiti SC","STKaiti",cursive';
11
11
  const TEXT_PIXEL_LIMIT = 524288;
12
12
  export function boundedTextResolution(width, height, dpr) {
13
13
  if (![width, height, dpr].every(value => Number.isFinite(value) && value > 0))
@@ -17,6 +17,16 @@ export function boundedTextResolution(width, height, dpr) {
17
17
  const textCache = new BoundedCache(160, 16 * 1024 * 1024);
18
18
  const glyphCache = new BoundedCache(512, 8 * 1024 * 1024);
19
19
  const clamp = (value) => Math.max(0, Math.min(1, value));
20
+ const Segmenter = Intl.Segmenter;
21
+ const graphemeSegmenter = Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : undefined;
22
+ const asciiText = /^[\x20-\x7e]*$/;
23
+ function letteringUnits(text) {
24
+ if (asciiText.test(text))
25
+ return Array.from(text);
26
+ return graphemeSegmenter
27
+ ? Array.from(graphemeSegmenter.segment(text), entry => entry.segment)
28
+ : [text];
29
+ }
20
30
  function hash(x, y, seed) {
21
31
  let value = Math.imul(x ^ seed, 374761393) ^ Math.imul(y, 668265263);
22
32
  value = Math.imul(value ^ (value >>> 13), 1274126177);
@@ -80,12 +90,14 @@ export function renderPencilText(text, options) {
80
90
  if (text.length > 256 || !Number.isFinite(size) || size <= 0 || size > 128) {
81
91
  throw new RangeError('Pencil text exceeds the bounded renderer input');
82
92
  }
93
+ // Normalize only this decorative rendering copy, never the selectable DOM.
94
+ text = text.normalize('NFC');
83
95
  let resolution = Math.max(2, Math.min(4, Math.ceil(dpr * 2)));
84
96
  const font = `${options.style ?? 'normal'} ${options.weight ?? '400'} ${size}px ${family}`;
85
97
  const fontReady = document.fonts?.check(font, text) ?? false;
86
98
  const key = cache ? JSON.stringify([
87
99
  PENCIL_RENDERER_VERSION,
88
- 'adaptive-text-raster-v1',
100
+ 'grapheme-text-raster-v2',
89
101
  text,
90
102
  size,
91
103
  font,
@@ -111,7 +123,7 @@ export function renderPencilText(text, options) {
111
123
  const rgb = measure.getImageData(0, 0, 1, 1).data;
112
124
  measure.font = font;
113
125
  measure.fontKerning = 'none';
114
- const characters = Array.from(text);
126
+ const characters = letteringUnits(text);
115
127
  const widths = characters.map(character => measure.measureText(character).width);
116
128
  const advance = Math.ceil(widths.reduce((sum, width) => sum + width, 0));
117
129
  const padding = 5;
@@ -130,9 +142,9 @@ export function renderPencilText(text, options) {
130
142
  if (!context)
131
143
  throw new Error('Canvas 2D is unavailable');
132
144
  context.scale(resolution, resolution);
133
- // Plain Latin labels can reuse bounded character sprites. Complex scripts and
134
- // private editable text retain the original whole-line rendering path.
135
- if (cache && options.glyphSeed !== undefined && /^[\x20-\x7e]+$/.test(text)) {
145
+ // Plain Latin labels reuse bounded character sprites. Other text is measured
146
+ // and painted as complete grapheme clusters, with one material pass per line.
147
+ if (cache && options.glyphSeed !== undefined && text.length > 0 && asciiText.test(text)) {
136
148
  let left = 0;
137
149
  characters.forEach((character, index) => {
138
150
  if (character !== ' ') {
@@ -11,14 +11,24 @@ import { PencilText } from '../PencilText.js';
11
11
  import { useInkraLocale } from '../i18n/index.js';
12
12
  import { PortalThemeBridge, PortalThemeProvider, usePortalTriggerRef } from '../overlay/themeBridge.js';
13
13
  import { assertInventoryContainers, projectInventoryMove, rotateInventoryCells, validateInventoryPlacement } from './model.js';
14
+ import { inventoryOutline } from './outline.js';
14
15
  export { assertInventoryContainers, projectInventoryMove, rotateInventoryCells, validateInventoryPlacement } from './model.js';
15
16
  const rotateIcon = rotateIconData.node.map(([, attributes]) => ({ d: String(attributes.d) }));
16
17
  const boardId = (id) => `inkra-inventory-board:${id}`;
17
18
  const itemId = (id) => `inkra-inventory-item:${id}`;
18
19
  const bounds = (cells) => ({ width: Math.max(...cells.map(cell => cell.x)) + 1, height: Math.max(...cells.map(cell => cell.y)) + 1 });
19
20
  const collisionDetection = args => args.pointerCoordinates ? pointerWithin(args) : rectIntersection(args);
20
- function Shape({ cells, size, seed, children, preview = false }) {
21
- return _jsx(_Fragment, { children: cells.map((cell, index) => _jsxs("span", { className: "inkra-inventory-grid__cell", style: { left: cell.x * size, top: cell.y * size, width: size, height: size }, children: [_jsx(PencilSurface, { as: "span", variant: preview ? 'selection' : 'quiet', radius: 3, seed: `${seed}-${cell.x}-${cell.y}`, className: "inkra-inventory-grid__cell-surface" }), !preview && _jsx(PencilSurface, { as: "span", variant: "outline", radius: 3, seed: `${seed}-${cell.x}-${cell.y}-focus`, className: "inkra-inventory-grid__cell-focus" }), index === 0 && _jsx("span", { className: "inkra-inventory-grid__item-content", children: children })] }, `${cell.x}:${cell.y}`)) });
21
+ function Shape({ cells, size, seed, children }) {
22
+ const outline = useMemo(() => inventoryOutline(cells, size), [cells, size]);
23
+ const box = bounds(cells);
24
+ return _jsxs(_Fragment, { children: [_jsxs("span", { className: "inkra-inventory-grid__shape", "aria-hidden": "true", style: { width: box.width * size, height: box.height * size }, children: [_jsx(PencilSurface, { as: "span", variant: "full", seed: seed, className: "inkra-inventory-grid__shape-fill", style: { clipPath: `path("${outline.path}")` }, children: _jsx(PencilSurface, { as: "span", variant: "selection", seed: `${seed}-wash`, className: "inkra-inventory-grid__shape-wash" }) }), _jsx("svg", { className: "inkra-inventory-grid__shape-fallback", width: "100%", height: "100%", children: _jsx("path", { d: outline.path, fill: "none", stroke: "currentColor" }) }), outline.segments.map(({ start, end }, index) => {
25
+ const vertical = start.x === end.x;
26
+ const style = { left: Math.min(start.x, end.x) - (vertical ? 4 : 1),
27
+ top: Math.min(start.y, end.y) - (vertical ? 1 : 4),
28
+ width: vertical ? 8 : Math.abs(end.x - start.x) + 2,
29
+ height: vertical ? Math.abs(end.y - start.y) + 2 : 8 };
30
+ return _jsx(PencilLine, { orientation: vertical ? 'vertical' : 'horizontal', seed: `${seed}-edge-${index}`, className: "inkra-inventory-grid__shape-edge", style: style }, index);
31
+ })] }), cells.map((cell, index) => _jsx("span", { className: "inkra-inventory-grid__cell", style: { left: cell.x * size, top: cell.y * size, width: size, height: size }, children: index === 0 && _jsx("span", { className: "inkra-inventory-grid__item-content", children: children }) }, `${cell.x}:${cell.y}`))] });
22
32
  }
23
33
  function ItemView({ item, container, size, disabled, selected, onSelect, onRotate, renderItem, getItemLabel, allowRotation }) {
24
34
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: itemId(item.id), disabled,
@@ -41,7 +51,7 @@ function ContainerView({ container, size, disabled, selectedId, onSelect, onRota
41
51
  const selected = container.items.find(item => item.id === selectedId);
42
52
  const projectedCells = projection && activeItem ? rotateInventoryCells(activeItem.cells, projection.rotation) : [];
43
53
  return _jsxs("section", { className: "inkra-inventory-grid__container", "aria-label": container.label ?? container.id, "data-disabled": container.disabled || undefined, children: [_jsxs("div", { className: "inkra-inventory-grid__header", children: [_jsx(PencilText, { children: container.label ?? container.id }), allowRotation && _jsx(Button, { variant: "ghost", size: "icon", "aria-label": rotateLabel, title: rotateLabel, disabled: disabled || container.disabled || !selected || selected.disabled || selected.rotatable === false, onClick: () => { if (selected)
44
- onRotate(selected.id); }, children: _jsx(PencilIcon, { paths: rotateIcon, size: 24 }) })] }), _jsx("div", { className: "inkra-inventory-grid__scroll", tabIndex: 0, role: "region", "aria-label": container.label ?? container.id, children: _jsxs("div", { ref: setNodeRef, className: "inkra-inventory-grid__board", "data-inventory-container": container.id, style: { width: container.columns * size, height: container.rows * size }, children: [_jsxs("div", { className: "inkra-inventory-grid__lines", "aria-hidden": "true", children: [Array.from({ length: container.columns + 1 }, (_, index) => _jsx(PencilLine, { orientation: "vertical", seed: `${container.id}-x${index}`, style: { left: index * size - 4, top: -1, width: 8, height: container.rows * size + 2 } }, `x${index}`)), Array.from({ length: container.rows + 1 }, (_, index) => _jsx(PencilLine, { seed: `${container.id}-y${index}`, style: { top: index * size - 4, left: -1, height: 8, width: container.columns * size + 2 } }, `y${index}`))] }), container.disabledCells?.map(cell => _jsx("span", { "aria-hidden": "true", className: "inkra-inventory-grid__blocked", style: { left: cell.x * size, top: cell.y * size, width: size, height: size }, children: _jsx(PencilIcon, { size: 24, paths: [{ d: 'M5 5l14 14M19 5L5 19' }] }) }, `${cell.x}:${cell.y}`)), container.items.map(item => _jsx(ItemView, { item: item, container: container, size: size, disabled: disabled || Boolean(container.disabled) || Boolean(item.disabled), selected: selectedId === item.id, onSelect: onSelect, onRotate: onRotate, renderItem: renderItem, getItemLabel: getItemLabel, allowRotation: allowRotation }, item.id)), projection && projection.containerId === container.id && _jsx("div", { "aria-hidden": "true", className: "inkra-inventory-grid__preview", "data-valid": valid, style: { left: projection.position.x * size, top: projection.position.y * size }, children: _jsx(Shape, { cells: projectedCells, size: size, seed: "inventory-preview", preview: true }) })] }) })] });
54
+ onRotate(selected.id); }, children: _jsx(PencilIcon, { paths: rotateIcon, size: 24 }) })] }), _jsx("div", { className: "inkra-inventory-grid__scroll", tabIndex: 0, role: "region", "aria-label": container.label ?? container.id, children: _jsxs("div", { ref: setNodeRef, className: "inkra-inventory-grid__board", "data-inventory-container": container.id, style: { width: container.columns * size, height: container.rows * size }, children: [_jsxs("div", { className: "inkra-inventory-grid__lines", "aria-hidden": "true", children: [Array.from({ length: container.columns + 1 }, (_, index) => _jsx(PencilLine, { orientation: "vertical", seed: `${container.id}-x${index}`, style: { left: index * size - 4, top: -1, width: 8, height: container.rows * size + 2 } }, `x${index}`)), Array.from({ length: container.rows + 1 }, (_, index) => _jsx(PencilLine, { seed: `${container.id}-y${index}`, style: { top: index * size - 4, left: -1, height: 8, width: container.columns * size + 2 } }, `y${index}`))] }), container.disabledCells?.map(cell => _jsx("span", { "aria-hidden": "true", className: "inkra-inventory-grid__blocked", style: { left: cell.x * size, top: cell.y * size, width: size, height: size }, children: _jsx(PencilIcon, { size: 24, paths: [{ d: 'M5 5l14 14M19 5L5 19' }] }) }, `${cell.x}:${cell.y}`)), container.items.map(item => _jsx(ItemView, { item: item, container: container, size: size, disabled: disabled || Boolean(container.disabled) || Boolean(item.disabled), selected: selectedId === item.id, onSelect: onSelect, onRotate: onRotate, renderItem: renderItem, getItemLabel: getItemLabel, allowRotation: allowRotation }, item.id)), projection && projection.containerId === container.id && _jsx("div", { "aria-hidden": "true", className: "inkra-inventory-grid__preview", "data-valid": valid, style: { left: projection.position.x * size, top: projection.position.y * size }, children: _jsx(Shape, { cells: projectedCells, size: size, seed: "inventory-preview" }) })] }) })] });
45
55
  }
46
56
  export function InventoryGrid(props) {
47
57
  return _jsx(PortalThemeProvider, { children: _jsx(InventoryGridContent, { ...props }) });
@@ -0,0 +1,9 @@
1
+ import type { InventoryCell } from './model.js';
2
+ /** Trace occupied-cell boundaries, keeping holes and disconnected islands separate. */
3
+ export declare function inventoryOutline(cells: readonly InventoryCell[], size: number, inset?: number): {
4
+ path: string;
5
+ segments: {
6
+ start: InventoryCell;
7
+ end: InventoryCell;
8
+ }[];
9
+ };