@inkra/react 0.6.0 → 0.9.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/README.md +166 -1
- package/dist/Button.js +1 -1
- package/dist/PencilIcon.d.ts +14 -1
- package/dist/PencilIcon.js +88 -52
- package/dist/PencilText.js +8 -0
- package/dist/form/Textarea.d.ts +4 -0
- package/dist/form/Textarea.js +14 -8
- package/dist/form/index.d.ts +1 -1
- package/dist/form/textareaSizing.d.ts +29 -0
- package/dist/form/textareaSizing.js +153 -0
- package/dist/guide/index.d.ts +77 -0
- package/dist/guide/index.js +379 -0
- package/dist/i18n/catalog.d.ts +1 -1
- package/dist/i18n/catalog.js +15 -0
- package/dist/image-viewer/index.d.ts +2 -0
- package/dist/image-viewer/index.js +28 -28
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/infinite-scroll/gesture.d.ts +19 -0
- package/dist/infinite-scroll/gesture.js +218 -0
- package/dist/infinite-scroll/index.d.ts +55 -0
- package/dist/infinite-scroll/index.js +543 -0
- package/dist/infinite-scroll/model.d.ts +33 -0
- package/dist/infinite-scroll/model.js +168 -0
- package/dist/internal/iconFill.d.ts +16 -0
- package/dist/internal/iconFill.js +275 -0
- package/dist/inventory-grid/index.d.ts +35 -0
- package/dist/inventory-grid/index.js +215 -0
- package/dist/inventory-grid/model.d.ts +56 -0
- package/dist/inventory-grid/model.js +93 -0
- package/dist/inventory-grid/outline.d.ts +9 -0
- package/dist/inventory-grid/outline.js +54 -0
- package/dist/layout/GridBoard.d.ts +22 -0
- package/dist/layout/GridBoard.js +113 -0
- package/dist/layout/GridResizable.d.ts +31 -0
- package/dist/layout/GridResizable.js +35 -0
- package/dist/layout/index.d.ts +19 -4
- package/dist/layout/index.js +45 -9
- package/dist/layout/swipeNavigation.d.ts +15 -0
- package/dist/layout/swipeNavigation.js +121 -0
- package/dist/loading/index.d.ts +58 -0
- package/dist/loading/index.js +188 -0
- package/dist/loading/locales.d.ts +15 -0
- package/dist/loading/locales.js +34 -0
- package/dist/loading/model.d.ts +6 -0
- package/dist/loading/model.js +29 -0
- package/dist/masonry/index.d.ts +25 -0
- package/dist/masonry/index.js +227 -0
- package/dist/masonry/layout.d.ts +35 -0
- package/dist/masonry/layout.js +67 -0
- package/dist/number-ticker/RollingValue.d.ts +6 -0
- package/dist/number-ticker/RollingValue.js +10 -0
- package/dist/number-ticker/index.d.ts +4 -0
- package/dist/number-ticker/index.js +55 -7
- package/dist/number-ticker/rolling.d.ts +35 -0
- package/dist/number-ticker/rolling.js +87 -0
- package/dist/pagination/index.d.ts +17 -0
- package/dist/pagination/index.js +30 -6
- package/dist/styles/base.css +9 -1
- package/dist/styles/form.css +10 -0
- package/dist/styles/guide.css +202 -0
- package/dist/styles/image-viewer.css +20 -0
- package/dist/styles/infinite-scroll.css +171 -0
- package/dist/styles/inventory-grid.css +43 -0
- package/dist/styles/layout.css +85 -0
- package/dist/styles/loading.css +348 -0
- package/dist/styles/masonry.css +68 -0
- package/dist/styles/number-ticker.css +46 -0
- package/dist/styles/pagination.css +10 -0
- package/dist/styles.css +1162 -146
- package/dist/toast/manager.d.ts +4 -4
- package/package.json +33 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { InventoryChangeDetails, InventoryContainer, InventoryItem, InventoryPlacementRule, InventoryRotation } from './model.js';
|
|
3
|
+
export { assertInventoryContainers, projectInventoryMove, rotateInventoryCells, validateInventoryPlacement } from './model.js';
|
|
4
|
+
export type { InventoryCell, InventoryChangeDetails, InventoryContainer, InventoryItem, InventoryPlacement, InventoryPlacementContext, InventoryPlacementResult, InventoryPlacementRule, InventoryRotation } from './model.js';
|
|
5
|
+
export interface InventoryGridLabels {
|
|
6
|
+
instructions?: string;
|
|
7
|
+
rotate?: string;
|
|
8
|
+
pickedUp?: (item: string) => string;
|
|
9
|
+
position?: (item: string, container: string, column: number, row: number, rotation: number) => string;
|
|
10
|
+
invalid?: string;
|
|
11
|
+
requested?: (item: string) => string;
|
|
12
|
+
cancelled?: (item: string) => string;
|
|
13
|
+
}
|
|
14
|
+
export interface InventoryItemRenderState {
|
|
15
|
+
selected: boolean;
|
|
16
|
+
dragging: boolean;
|
|
17
|
+
rotation: InventoryRotation;
|
|
18
|
+
overlay: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface InventoryGridProps<Item extends InventoryItem = InventoryItem> {
|
|
21
|
+
containers: readonly InventoryContainer<Item>[];
|
|
22
|
+
onChange: (containers: readonly InventoryContainer<Item>[], details: InventoryChangeDetails) => void;
|
|
23
|
+
canPlace?: InventoryPlacementRule<Item>;
|
|
24
|
+
/** Content is placed inside the first occupied cell; keep it non-interactive. */
|
|
25
|
+
renderItem?: (item: Item, state: InventoryItemRenderState) => ReactNode;
|
|
26
|
+
getItemLabel?: (item: Item) => string;
|
|
27
|
+
cellSize?: number;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
readOnly?: boolean;
|
|
30
|
+
allowRotation?: boolean;
|
|
31
|
+
labels?: InventoryGridLabels;
|
|
32
|
+
className?: string;
|
|
33
|
+
'aria-label'?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function InventoryGrid<Item extends InventoryItem>(props: InventoryGridProps<Item>): import("react").JSX.Element;
|