@swiftbrowser/web 0.1.0 → 0.2.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 +43 -6
- package/app/index.html +11 -0
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +11 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +18 -1
- package/src/landing.ts +2 -2
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +279 -25
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +53 -3
- package/src/main.ts +124 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +2 -1
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +761 -12
- package/src/renderer.ts +584 -36
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +212 -7
- package/src/styles.ts +31 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +564 -21
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
package/src/imageOps.ts
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pixel side of Core Image: the steps of a bitmap recipe (Core Image's
|
|
3
|
+
* built-in filters, crops and renders) run on a raster in plain TypeScript,
|
|
4
|
+
* so they behave the same in every browser and in Node's unit tests. No DOM
|
|
5
|
+
* here; src/bitmapStore.ts decodes the sources and src/bitmaps.ts puts the
|
|
6
|
+
* result on a canvas.
|
|
7
|
+
* The formulas are in docs/ops-protocol.md, "Bitmaps and Core Image".
|
|
8
|
+
*
|
|
9
|
+
* A raster covers a rectangle of Core Image's space (units of source pixels,
|
|
10
|
+
* origin at the bottom left, y up) at `scale` raster pixels per unit; the
|
|
11
|
+
* page usually works below full resolution (scale < 1), at the size the
|
|
12
|
+
* image is shown, and every length a step takes (a radius, a cell size) is
|
|
13
|
+
* scaled with it. Pixels are premultiplied RGBA floats in 0…1, rows from the
|
|
14
|
+
* top, as Core Image works premultiplied: a blur then fades to clear instead
|
|
15
|
+
* of to black.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { BitmapPoint, BitmapStep } from './protocol';
|
|
19
|
+
|
|
20
|
+
export interface Raster {
|
|
21
|
+
/** Size in raster pixels. */
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
/** Premultiplied RGBA, 0…1, `width * height * 4` values, rows from the top. */
|
|
25
|
+
data: Float32Array;
|
|
26
|
+
/** The rectangle covered, in Core Image units (origin at the bottom left). */
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
w: number;
|
|
30
|
+
h: number;
|
|
31
|
+
/** Raster pixels per Core Image unit. */
|
|
32
|
+
scale: number;
|
|
33
|
+
/** `clampedToExtent()`: outside its rectangle the image repeats its edge pixels instead of being clear. */
|
|
34
|
+
clamped: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Rect {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An empty raster covering `rect` at `scale` (its pixel size rounded, at least 0). */
|
|
45
|
+
export function makeRaster(rect: Rect, scale: number, clamped = false): Raster {
|
|
46
|
+
const width = Math.max(0, Math.round(rect.width * scale));
|
|
47
|
+
const height = Math.max(0, Math.round(rect.height * scale));
|
|
48
|
+
return { width, height, data: new Float32Array(width * height * 4), x: rect.x, y: rect.y, w: rect.width, h: rect.height, scale, clamped };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A raster from straight (not premultiplied) RGBA bytes. */
|
|
52
|
+
export function rasterFromRGBA(rgba: ArrayLike<number>, width: number, height: number, rect: Rect, scale: number): Raster {
|
|
53
|
+
const raster: Raster = { width, height, data: new Float32Array(width * height * 4), x: rect.x, y: rect.y, w: rect.width, h: rect.height, scale, clamped: false };
|
|
54
|
+
const out = raster.data;
|
|
55
|
+
for (let i = 0; i < width * height * 4; i += 4) {
|
|
56
|
+
const a = rgba[i + 3]! / 255;
|
|
57
|
+
out[i] = (rgba[i]! / 255) * a;
|
|
58
|
+
out[i + 1] = (rgba[i + 1]! / 255) * a;
|
|
59
|
+
out[i + 2] = (rgba[i + 2]! / 255) * a;
|
|
60
|
+
out[i + 3] = a;
|
|
61
|
+
}
|
|
62
|
+
return raster;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Straight RGBA bytes from a raster (what `ImageData` holds). */
|
|
66
|
+
export function rasterToRGBA(raster: Raster): Uint8ClampedArray<ArrayBuffer> {
|
|
67
|
+
const out = new Uint8ClampedArray(raster.width * raster.height * 4);
|
|
68
|
+
const data = raster.data;
|
|
69
|
+
for (let i = 0; i < out.length; i += 4) {
|
|
70
|
+
const a = data[i + 3]!;
|
|
71
|
+
if (a <= 0) continue;
|
|
72
|
+
out[i] = Math.round((data[i]! / a) * 255);
|
|
73
|
+
out[i + 1] = Math.round((data[i + 1]! / a) * 255);
|
|
74
|
+
out[i + 2] = Math.round((data[i + 2]! / a) * 255);
|
|
75
|
+
out[i + 3] = Math.round(a * 255);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Runs `steps` in order. */
|
|
81
|
+
export function applySteps(raster: Raster, steps: readonly BitmapStep[]): Raster {
|
|
82
|
+
let current = raster;
|
|
83
|
+
for (const step of steps) current = applyStep(current, step);
|
|
84
|
+
return current;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function applyStep(r: Raster, step: BitmapStep): Raster {
|
|
88
|
+
switch (step.op) {
|
|
89
|
+
case 'crop':
|
|
90
|
+
return resample(r, r.clamped ? step : intersect(step, r), false);
|
|
91
|
+
case 'render': {
|
|
92
|
+
const out = resample(r, step, false);
|
|
93
|
+
return { ...out, x: 0, y: 0 };
|
|
94
|
+
}
|
|
95
|
+
case 'clamp':
|
|
96
|
+
return { ...r, clamped: true };
|
|
97
|
+
case 'sepiaTone':
|
|
98
|
+
return sepiaTone(r, step.intensity);
|
|
99
|
+
case 'edges':
|
|
100
|
+
return edges(r, step.intensity);
|
|
101
|
+
case 'crystallize':
|
|
102
|
+
return crystallize(r, step.radius, step.center);
|
|
103
|
+
case 'pixellate':
|
|
104
|
+
return pixellate(r, step.scale, step.center);
|
|
105
|
+
case 'gaussianBlur':
|
|
106
|
+
return gaussianBlur(r, step.radius);
|
|
107
|
+
case 'unsharpMask':
|
|
108
|
+
return unsharpMask(r, step.radius, step.intensity);
|
|
109
|
+
case 'vignette':
|
|
110
|
+
return vignette(r, step.intensity, step.radius, step.center, step.size);
|
|
111
|
+
case 'place':
|
|
112
|
+
return place(r, step.sx, step.sy, step.tx, step.ty);
|
|
113
|
+
case 'roundedClip':
|
|
114
|
+
return roundedClip(r, step, step.rx, step.ry);
|
|
115
|
+
case 'tint':
|
|
116
|
+
return tint(r, step.r, step.g, step.b, step.a);
|
|
117
|
+
default: {
|
|
118
|
+
// An op from a newer module: skip it rather than fail the image.
|
|
119
|
+
return r;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Coordinates
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function intersect(a: Rect, r: Raster): Rect {
|
|
129
|
+
const x0 = Math.max(a.x, r.x);
|
|
130
|
+
const y0 = Math.max(a.y, r.y);
|
|
131
|
+
const x1 = Math.min(a.x + a.width, r.x + r.w);
|
|
132
|
+
const y1 = Math.min(a.y + a.height, r.y + r.h);
|
|
133
|
+
return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The Core Image point at the center of raster pixel (i, j). */
|
|
137
|
+
function pointOf(r: Raster, i: number, j: number): BitmapPoint {
|
|
138
|
+
return [r.x + ((i + 0.5) * r.w) / r.width, r.y + r.h - ((j + 0.5) * r.h) / r.height];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The raster pixel holding a Core Image point, clamped to the raster. */
|
|
142
|
+
function pixelAt(r: Raster, px: number, py: number): number {
|
|
143
|
+
const i = Math.min(r.width - 1, Math.max(0, Math.floor(((px - r.x) * r.width) / r.w)));
|
|
144
|
+
const j = Math.min(r.height - 1, Math.max(0, Math.floor(((r.y + r.h - py) * r.height) / r.h)));
|
|
145
|
+
return (j * r.width + i) * 4;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The image over `rect` at the same scale: each new pixel takes the pixel of
|
|
150
|
+
* `r` at its center; outside `r` it is clear, or the nearest edge pixel when
|
|
151
|
+
* `r` is clamped.
|
|
152
|
+
*/
|
|
153
|
+
function resample(r: Raster, rect: Rect, clamped: boolean): Raster {
|
|
154
|
+
const out = makeRaster(rect, r.scale, clamped);
|
|
155
|
+
if (r.width === 0 || r.height === 0) return out;
|
|
156
|
+
const src = r.data;
|
|
157
|
+
const dst = out.data;
|
|
158
|
+
for (let j = 0; j < out.height; j++) {
|
|
159
|
+
const py = out.y + out.h - ((j + 0.5) * out.h) / out.height;
|
|
160
|
+
const sj = Math.floor(((r.y + r.h - py) * r.height) / r.h);
|
|
161
|
+
if (!r.clamped && (sj < 0 || sj >= r.height)) continue;
|
|
162
|
+
const row = Math.min(r.height - 1, Math.max(0, sj)) * r.width;
|
|
163
|
+
for (let i = 0; i < out.width; i++) {
|
|
164
|
+
const px = out.x + ((i + 0.5) * out.w) / out.width;
|
|
165
|
+
const si = Math.floor(((px - r.x) * r.width) / r.w);
|
|
166
|
+
if (!r.clamped && (si < 0 || si >= r.width)) continue;
|
|
167
|
+
const from = (row + Math.min(r.width - 1, Math.max(0, si))) * 4;
|
|
168
|
+
const to = (j * out.width + i) * 4;
|
|
169
|
+
dst[to] = src[from]!;
|
|
170
|
+
dst[to + 1] = src[from + 1]!;
|
|
171
|
+
dst[to + 2] = src[from + 2]!;
|
|
172
|
+
dst[to + 3] = src[from + 3]!;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Core Graphics' `draw(_:in:)`: the image scaled by (sx, sy) and moved by
|
|
180
|
+
* (tx, ty). A uniform scale only relabels the raster (the same pixels cover
|
|
181
|
+
* the new rectangle); a non-uniform one resamples it, at the finer of the two
|
|
182
|
+
* resolutions.
|
|
183
|
+
*/
|
|
184
|
+
export function place(r: Raster, sx: number, sy: number, tx: number, ty: number): Raster {
|
|
185
|
+
if (!(sx > 0) || !(sy > 0)) return makeRaster({ x: tx, y: ty, width: 0, height: 0 }, r.scale);
|
|
186
|
+
const rect = { x: r.x * sx + tx, y: r.y * sy + ty, width: r.w * sx, height: r.h * sy };
|
|
187
|
+
if (Math.abs(sx - sy) <= 1e-9 * Math.max(sx, sy)) {
|
|
188
|
+
return { ...r, x: rect.x, y: rect.y, w: rect.width, h: rect.height, scale: r.scale / sx };
|
|
189
|
+
}
|
|
190
|
+
const out = makeRaster(rect, r.scale / Math.min(sx, sy), r.clamped);
|
|
191
|
+
if (r.width === 0 || r.height === 0) return out;
|
|
192
|
+
const src = r.data;
|
|
193
|
+
const dst = out.data;
|
|
194
|
+
for (let j = 0; j < out.height; j++) {
|
|
195
|
+
// The output pixel's center, back in the source's units.
|
|
196
|
+
const py = (out.y + out.h - ((j + 0.5) * out.h) / out.height - ty) / sy;
|
|
197
|
+
const sj = Math.min(r.height - 1, Math.max(0, Math.floor(((r.y + r.h - py) * r.height) / r.h)));
|
|
198
|
+
for (let i = 0; i < out.width; i++) {
|
|
199
|
+
const px = (out.x + ((i + 0.5) * out.w) / out.width - tx) / sx;
|
|
200
|
+
const si = Math.min(r.width - 1, Math.max(0, Math.floor(((px - r.x) * r.width) / r.w)));
|
|
201
|
+
const from = (sj * r.width + si) * 4;
|
|
202
|
+
const to = (j * out.width + i) * 4;
|
|
203
|
+
dst[to] = src[from]!;
|
|
204
|
+
dst[to + 1] = src[from + 1]!;
|
|
205
|
+
dst[to + 2] = src[from + 2]!;
|
|
206
|
+
dst[to + 3] = src[from + 3]!;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Keeps what lies inside a rounded rectangle (corner radii rx, ry), with a
|
|
214
|
+
* one-raster-pixel soft edge, and clears the rest.
|
|
215
|
+
*/
|
|
216
|
+
export function roundedClip(r: Raster, rect: Rect, rx: number, ry: number): Raster {
|
|
217
|
+
const out = clone(r);
|
|
218
|
+
const data = out.data;
|
|
219
|
+
const radiusX = Math.max(0, Math.min(rx, rect.width / 2));
|
|
220
|
+
const radiusY = Math.max(0, Math.min(ry, rect.height / 2));
|
|
221
|
+
const unit = 1 / r.scale; // one raster pixel, in units
|
|
222
|
+
for (let j = 0; j < r.height; j++) {
|
|
223
|
+
const py = r.y + r.h - ((j + 0.5) * r.h) / r.height;
|
|
224
|
+
for (let i = 0; i < r.width; i++) {
|
|
225
|
+
const px = r.x + ((i + 0.5) * r.w) / r.width;
|
|
226
|
+
const coverage = roundedRectCoverage(px, py, rect, radiusX, radiusY, unit);
|
|
227
|
+
if (coverage >= 1) continue;
|
|
228
|
+
const k = (j * r.width + i) * 4;
|
|
229
|
+
data[k] = data[k]! * coverage;
|
|
230
|
+
data[k + 1] = data[k + 1]! * coverage;
|
|
231
|
+
data[k + 2] = data[k + 2]! * coverage;
|
|
232
|
+
data[k + 3] = data[k + 3]! * coverage;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The image's shape in one color: each pixel's alpha times the color's, premultiplied. */
|
|
239
|
+
export function tint(r: Raster, red: number, green: number, blue: number, alpha: number): Raster {
|
|
240
|
+
const out = clone(r);
|
|
241
|
+
const data = out.data;
|
|
242
|
+
const c = (v: number) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
|
243
|
+
const [cr, cg, cb, ca] = [c(red), c(green), c(blue), c(alpha)];
|
|
244
|
+
for (let k = 0; k < data.length; k += 4) {
|
|
245
|
+
const a = data[k + 3]! * ca;
|
|
246
|
+
data[k] = cr * a;
|
|
247
|
+
data[k + 1] = cg * a;
|
|
248
|
+
data[k + 2] = cb * a;
|
|
249
|
+
data[k + 3] = a;
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** How much of a pixel at (px, py) lies inside the rounded rectangle, 0…1, from its distance to the edge. */
|
|
255
|
+
function roundedRectCoverage(px: number, py: number, rect: Rect, rx: number, ry: number, unit: number): number {
|
|
256
|
+
// Distance outside the straight edges (negative inside).
|
|
257
|
+
const dx = Math.max(rect.x - px, px - (rect.x + rect.width));
|
|
258
|
+
const dy = Math.max(rect.y - py, py - (rect.y + rect.height));
|
|
259
|
+
let outside = Math.max(dx, dy);
|
|
260
|
+
if (rx > 0 && ry > 0) {
|
|
261
|
+
// In a corner square: the distance to the corner's ellipse, scaled to the smaller radius.
|
|
262
|
+
const cx = px < rect.x + rx ? rect.x + rx : px > rect.x + rect.width - rx ? rect.x + rect.width - rx : null;
|
|
263
|
+
const cy = py < rect.y + ry ? rect.y + ry : py > rect.y + rect.height - ry ? rect.y + rect.height - ry : null;
|
|
264
|
+
if (cx !== null && cy !== null) {
|
|
265
|
+
const ex = (px - cx) / rx;
|
|
266
|
+
const ey = (py - cy) / ry;
|
|
267
|
+
outside = (Math.sqrt(ex * ex + ey * ey) - 1) * Math.min(rx, ry);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return Math.min(1, Math.max(0, 0.5 - outside / unit));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clone(r: Raster): Raster {
|
|
274
|
+
return { ...r, data: new Float32Array(r.data) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Keeps each color channel within its premultiplied range, 0…alpha. */
|
|
278
|
+
function clampColor(data: Float32Array, i: number): void {
|
|
279
|
+
const a = data[i + 3]!;
|
|
280
|
+
for (let c = 0; c < 3; c++) {
|
|
281
|
+
const v = data[i + c]!;
|
|
282
|
+
data[i + c] = v < 0 ? 0 : v > a ? a : v;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Color
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `CISepiaTone`: the classic sepia matrix (the one CSS `sepia()` uses), mixed
|
|
292
|
+
* with the original by `intensity`. Linear, so it applies to premultiplied
|
|
293
|
+
* color as it is.
|
|
294
|
+
*/
|
|
295
|
+
export function sepiaTone(r: Raster, intensity: number): Raster {
|
|
296
|
+
const out = clone(r);
|
|
297
|
+
const d = out.data;
|
|
298
|
+
const k = intensity;
|
|
299
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
300
|
+
const red = d[i]!, green = d[i + 1]!, blue = d[i + 2]!;
|
|
301
|
+
const sr = 0.393 * red + 0.769 * green + 0.189 * blue;
|
|
302
|
+
const sg = 0.349 * red + 0.686 * green + 0.168 * blue;
|
|
303
|
+
const sb = 0.272 * red + 0.534 * green + 0.131 * blue;
|
|
304
|
+
d[i] = red + k * (sr - red);
|
|
305
|
+
d[i + 1] = green + k * (sg - green);
|
|
306
|
+
d[i + 2] = blue + k * (sb - blue);
|
|
307
|
+
clampColor(d, i);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* `CIVignette`: natural (cos⁴) falloff. With d the distance from `center`
|
|
314
|
+
* over `size` (half the input's diagonal, so 1 at the corners), the angle is
|
|
315
|
+
* θ = atan(d · radius) and color is scaled by 1 + intensity · (cos⁴θ − 1).
|
|
316
|
+
* Radius 0 changes nothing (Core Image's identity value); a larger radius
|
|
317
|
+
* darkens more of the picture, and a negative intensity brightens.
|
|
318
|
+
*/
|
|
319
|
+
export function vignette(r: Raster, intensity: number, radius: number, center: BitmapPoint, size: number): Raster {
|
|
320
|
+
const out = clone(r);
|
|
321
|
+
const d = out.data;
|
|
322
|
+
const spread = Math.max(radius, 0) / Math.max(size, 1e-6);
|
|
323
|
+
for (let j = 0; j < r.height; j++) {
|
|
324
|
+
for (let i = 0; i < r.width; i++) {
|
|
325
|
+
const [px, py] = pointOf(r, i, j);
|
|
326
|
+
const t = Math.hypot(px - center[0], py - center[1]) * spread;
|
|
327
|
+
const cos = Math.cos(Math.atan(t));
|
|
328
|
+
const factor = Math.max(0, 1 + intensity * (cos * cos * cos * cos - 1));
|
|
329
|
+
const k = (j * r.width + i) * 4;
|
|
330
|
+
d[k] = d[k]! * factor;
|
|
331
|
+
d[k + 1] = d[k + 1]! * factor;
|
|
332
|
+
d[k + 2] = d[k + 2]! * factor;
|
|
333
|
+
clampColor(d, k);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Neighborhoods
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `CIEdges`: the Sobel gradient of each color channel; the channel becomes
|
|
345
|
+
* intensity × the gradient's magnitude (capped at 1), on black. Alpha is kept.
|
|
346
|
+
* Pixels past the border repeat the edge.
|
|
347
|
+
*/
|
|
348
|
+
export function edges(r: Raster, intensity: number): Raster {
|
|
349
|
+
const out = clone(r);
|
|
350
|
+
const s = r.data;
|
|
351
|
+
const d = out.data;
|
|
352
|
+
const w = r.width;
|
|
353
|
+
const h = r.height;
|
|
354
|
+
const at = (i: number, j: number, c: number): number =>
|
|
355
|
+
s[((Math.min(h - 1, Math.max(0, j)) * w + Math.min(w - 1, Math.max(0, i))) * 4) + c]!;
|
|
356
|
+
for (let j = 0; j < h; j++) {
|
|
357
|
+
for (let i = 0; i < w; i++) {
|
|
358
|
+
const k = (j * w + i) * 4;
|
|
359
|
+
const a = s[k + 3]!;
|
|
360
|
+
for (let c = 0; c < 3; c++) {
|
|
361
|
+
const gx =
|
|
362
|
+
at(i + 1, j - 1, c) + 2 * at(i + 1, j, c) + at(i + 1, j + 1, c) - at(i - 1, j - 1, c) - 2 * at(i - 1, j, c) - at(i - 1, j + 1, c);
|
|
363
|
+
const gy =
|
|
364
|
+
at(i - 1, j + 1, c) + 2 * at(i, j + 1, c) + at(i + 1, j + 1, c) - at(i - 1, j - 1, c) - 2 * at(i, j - 1, c) - at(i + 1, j - 1, c);
|
|
365
|
+
d[k + c] = Math.min(1, intensity * Math.hypot(gx, gy)) * a;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `CIPixellate`: square cells `scale` units wide on a grid through `center`;
|
|
374
|
+
* each cell takes the input's color at its middle.
|
|
375
|
+
*/
|
|
376
|
+
export function pixellate(r: Raster, scale: number, center: BitmapPoint): Raster {
|
|
377
|
+
const out = clone(r);
|
|
378
|
+
if (scale <= 0) return out;
|
|
379
|
+
const s = r.data;
|
|
380
|
+
const d = out.data;
|
|
381
|
+
for (let j = 0; j < r.height; j++) {
|
|
382
|
+
for (let i = 0; i < r.width; i++) {
|
|
383
|
+
const [px, py] = pointOf(r, i, j);
|
|
384
|
+
const cx = center[0] + (Math.floor((px - center[0]) / scale) + 0.5) * scale;
|
|
385
|
+
const cy = center[1] + (Math.floor((py - center[1]) / scale) + 0.5) * scale;
|
|
386
|
+
const from = pixelAt(r, cx, cy);
|
|
387
|
+
const to = (j * r.width + i) * 4;
|
|
388
|
+
d[to] = s[from]!;
|
|
389
|
+
d[to + 1] = s[from + 1]!;
|
|
390
|
+
d[to + 2] = s[from + 2]!;
|
|
391
|
+
d[to + 3] = s[from + 3]!;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A point in 0…1² for grid cell (i, j), the same on every run. */
|
|
398
|
+
export function cellJitter(i: number, j: number): BitmapPoint {
|
|
399
|
+
let h = Math.imul(i, 0x27d4eb2d) ^ Math.imul(j, 0x165667b1);
|
|
400
|
+
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
|
|
401
|
+
h ^= h >>> 13;
|
|
402
|
+
h = Math.imul(h, 0xc2b2ae35);
|
|
403
|
+
h ^= h >>> 16;
|
|
404
|
+
return [(h & 0xffff) / 65536, (h >>> 16) / 65536];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* `CICrystallize`: a Voronoi diagram. One seed per cell of a grid `radius`
|
|
409
|
+
* units wide through `center`, placed at a fixed pseudo-random point of its
|
|
410
|
+
* cell; every pixel takes the input's color at its nearest seed.
|
|
411
|
+
*/
|
|
412
|
+
export function crystallize(r: Raster, radius: number, center: BitmapPoint): Raster {
|
|
413
|
+
const out = clone(r);
|
|
414
|
+
const pitch = Math.max(radius, 1e-6);
|
|
415
|
+
const s = r.data;
|
|
416
|
+
const d = out.data;
|
|
417
|
+
for (let j = 0; j < r.height; j++) {
|
|
418
|
+
for (let i = 0; i < r.width; i++) {
|
|
419
|
+
const [px, py] = pointOf(r, i, j);
|
|
420
|
+
const gx = Math.floor((px - center[0]) / pitch);
|
|
421
|
+
const gy = Math.floor((py - center[1]) / pitch);
|
|
422
|
+
let best = Infinity;
|
|
423
|
+
let sx = px;
|
|
424
|
+
let sy = py;
|
|
425
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
426
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
427
|
+
const [jx, jy] = cellJitter(gx + dx, gy + dy);
|
|
428
|
+
const x = center[0] + (gx + dx + jx) * pitch;
|
|
429
|
+
const y = center[1] + (gy + dy + jy) * pitch;
|
|
430
|
+
const dist = (x - px) * (x - px) + (y - py) * (y - py);
|
|
431
|
+
if (dist < best) {
|
|
432
|
+
best = dist;
|
|
433
|
+
sx = x;
|
|
434
|
+
sy = y;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const from = pixelAt(r, sx, sy);
|
|
439
|
+
const to = (j * r.width + i) * 4;
|
|
440
|
+
d[to] = s[from]!;
|
|
441
|
+
d[to + 1] = s[from + 1]!;
|
|
442
|
+
d[to + 2] = s[from + 2]!;
|
|
443
|
+
d[to + 3] = s[from + 3]!;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Blur
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Box widths whose three successive passes approximate a Gaussian of
|
|
455
|
+
* standard deviation `sigma` (Kovesi, "Fast almost-Gaussian filtering").
|
|
456
|
+
*/
|
|
457
|
+
export function boxesForGauss(sigma: number, n = 3): number[] {
|
|
458
|
+
const ideal = Math.sqrt((12 * sigma * sigma) / n + 1);
|
|
459
|
+
let lower = Math.floor(ideal);
|
|
460
|
+
if (lower % 2 === 0) lower -= 1;
|
|
461
|
+
const upper = lower + 2;
|
|
462
|
+
const m = Math.round((12 * sigma * sigma - n * lower * lower - 4 * n * lower - 3 * n) / (-4 * lower - 4));
|
|
463
|
+
return Array.from({ length: n }, (_, i) => (i < m ? lower : upper));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Blurs every channel of `data` in place with a Gaussian of `sigma` raster
|
|
468
|
+
* pixels: three box passes per axis. Past the border the image is clear
|
|
469
|
+
* (`edge: 'clear'`) or repeats its edge pixels (`edge: 'clamp'`).
|
|
470
|
+
*/
|
|
471
|
+
export function blurInPlace(data: Float32Array, width: number, height: number, sigma: number, edge: 'clear' | 'clamp'): void {
|
|
472
|
+
if (sigma < 0.3 || width === 0 || height === 0) return;
|
|
473
|
+
const line = new Float32Array(Math.max(width, height) * 4);
|
|
474
|
+
const result = new Float32Array(Math.max(width, height) * 4);
|
|
475
|
+
for (const box of boxesForGauss(sigma)) {
|
|
476
|
+
const radius = (box - 1) / 2;
|
|
477
|
+
if (radius < 1) continue;
|
|
478
|
+
for (let j = 0; j < height; j++) {
|
|
479
|
+
line.set(data.subarray(j * width * 4, (j + 1) * width * 4));
|
|
480
|
+
boxLine(line, result, width, radius, edge);
|
|
481
|
+
data.set(result.subarray(0, width * 4), j * width * 4);
|
|
482
|
+
}
|
|
483
|
+
for (let i = 0; i < width; i++) {
|
|
484
|
+
for (let j = 0; j < height; j++) {
|
|
485
|
+
const k = (j * width + i) * 4;
|
|
486
|
+
line[j * 4] = data[k]!;
|
|
487
|
+
line[j * 4 + 1] = data[k + 1]!;
|
|
488
|
+
line[j * 4 + 2] = data[k + 2]!;
|
|
489
|
+
line[j * 4 + 3] = data[k + 3]!;
|
|
490
|
+
}
|
|
491
|
+
boxLine(line, result, height, radius, edge);
|
|
492
|
+
for (let j = 0; j < height; j++) {
|
|
493
|
+
const k = (j * width + i) * 4;
|
|
494
|
+
data[k] = result[j * 4]!;
|
|
495
|
+
data[k + 1] = result[j * 4 + 1]!;
|
|
496
|
+
data[k + 2] = result[j * 4 + 2]!;
|
|
497
|
+
data[k + 3] = result[j * 4 + 3]!;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** One box pass over `n` RGBA pixels of `src` into `dst`: a running sum of 2·radius + 1 pixels. */
|
|
504
|
+
function boxLine(src: Float32Array, dst: Float32Array, n: number, radius: number, edge: 'clear' | 'clamp'): void {
|
|
505
|
+
const norm = 1 / (2 * radius + 1);
|
|
506
|
+
const value = (index: number, c: number): number => {
|
|
507
|
+
if (index < 0) return edge === 'clamp' ? src[c]! : 0;
|
|
508
|
+
if (index >= n) return edge === 'clamp' ? src[(n - 1) * 4 + c]! : 0;
|
|
509
|
+
return src[index * 4 + c]!;
|
|
510
|
+
};
|
|
511
|
+
for (let c = 0; c < 4; c++) {
|
|
512
|
+
let sum = 0;
|
|
513
|
+
for (let k = -radius; k <= radius; k++) sum += value(k, c);
|
|
514
|
+
for (let i = 0; i < n; i++) {
|
|
515
|
+
dst[i * 4 + c] = sum * norm;
|
|
516
|
+
sum += value(i + radius + 1, c) - value(i - radius, c);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* `CIGaussianBlur`: sigma = radius. The image grows by ⌈3·radius⌉ units on
|
|
523
|
+
* every side and fades to clear there, as Core Image's extent does; a
|
|
524
|
+
* clamped image keeps its rectangle and blurs against its repeated edges.
|
|
525
|
+
*/
|
|
526
|
+
export function gaussianBlur(r: Raster, radius: number): Raster {
|
|
527
|
+
const sigma = radius * r.scale;
|
|
528
|
+
if (r.clamped) {
|
|
529
|
+
const out = clone(r);
|
|
530
|
+
blurInPlace(out.data, out.width, out.height, sigma, 'clamp');
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
const reach = Math.ceil(3 * radius);
|
|
534
|
+
const pad = Math.round(reach * r.scale);
|
|
535
|
+
const out: Raster = {
|
|
536
|
+
width: r.width + 2 * pad,
|
|
537
|
+
height: r.height + 2 * pad,
|
|
538
|
+
data: new Float32Array((r.width + 2 * pad) * (r.height + 2 * pad) * 4),
|
|
539
|
+
x: r.x - reach,
|
|
540
|
+
y: r.y - reach,
|
|
541
|
+
w: r.w + 2 * reach,
|
|
542
|
+
h: r.h + 2 * reach,
|
|
543
|
+
scale: r.scale,
|
|
544
|
+
clamped: false,
|
|
545
|
+
};
|
|
546
|
+
for (let j = 0; j < r.height; j++) {
|
|
547
|
+
out.data.set(r.data.subarray(j * r.width * 4, (j + 1) * r.width * 4), ((j + pad) * out.width + pad) * 4);
|
|
548
|
+
}
|
|
549
|
+
blurInPlace(out.data, out.width, out.height, sigma, 'clear');
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* `CIUnsharpMask`: the image plus intensity × (the image − its Gaussian blur
|
|
555
|
+
* of sigma = radius, against repeated edges). Alpha is kept.
|
|
556
|
+
*/
|
|
557
|
+
export function unsharpMask(r: Raster, radius: number, intensity: number): Raster {
|
|
558
|
+
const blurred = clone(r);
|
|
559
|
+
blurInPlace(blurred.data, r.width, r.height, radius * r.scale, 'clamp');
|
|
560
|
+
const out = clone(r);
|
|
561
|
+
const d = out.data;
|
|
562
|
+
const b = blurred.data;
|
|
563
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
564
|
+
for (let c = 0; c < 3; c++) d[i + c] = d[i + c]! + intensity * (d[i + c]! - b[i + c]!);
|
|
565
|
+
clampColor(d, i);
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Scale
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
/** The most raster pixels a step works on; a larger source is worked on smaller. */
|
|
575
|
+
export const MAX_WORKING_PIXELS = 16_000_000;
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The scale to work at: enough raster pixels for an image of `width` ×
|
|
579
|
+
* `height` units shown on `targetWidth` × `targetHeight` device pixels, never
|
|
580
|
+
* above the source's own resolution (1) nor above `MAX_WORKING_PIXELS` for a
|
|
581
|
+
* source of `sourcePixels`. Rounded up to a power of √2, so a box that grows
|
|
582
|
+
* or shrinks a little (an animation) does not redraw the image.
|
|
583
|
+
*/
|
|
584
|
+
export function workingScale(width: number, height: number, targetWidth: number, targetHeight: number, sourcePixels: number): number {
|
|
585
|
+
if (width <= 0 || height <= 0) return 1;
|
|
586
|
+
let scale = Math.max(targetWidth / width, targetHeight / height);
|
|
587
|
+
if (!Number.isFinite(scale) || scale <= 0) scale = 1;
|
|
588
|
+
scale = Math.pow(Math.SQRT2, Math.ceil(Math.log(scale) / Math.log(Math.SQRT2) - 1e-9));
|
|
589
|
+
scale = Math.min(1, scale);
|
|
590
|
+
if (sourcePixels * scale * scale > MAX_WORKING_PIXELS) scale = Math.sqrt(MAX_WORKING_PIXELS / sourcePixels);
|
|
591
|
+
return scale;
|
|
592
|
+
}
|
package/src/interaction.ts
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* reach the renderer's click handler untouched, so controls inside gestures
|
|
25
25
|
* and rows keep working.
|
|
26
26
|
*
|
|
27
|
+
* A horizontal scroll view keeps horizontal motion (touch or wheel) from the
|
|
28
|
+
* swipe rows around it, as UIKit's inner scroll view wins over a row's swipe.
|
|
29
|
+
*
|
|
27
30
|
* Horizontal wheel scrolling slides swipe rows and vertical wheel at the
|
|
28
31
|
* top of a `refreshable` scroller pulls to refresh; touch moves are read
|
|
29
32
|
* directly for pull to refresh (the browser cancels the pointer once it
|
|
@@ -379,8 +382,11 @@ export class InteractionController {
|
|
|
379
382
|
out.push({ kind: 'swipeback', screen, id, width: rect.width || screen.offsetWidth, below: below?.classList.contains('sb-navscreen') ? below : null });
|
|
380
383
|
}
|
|
381
384
|
}
|
|
385
|
+
// A horizontal scroll view takes horizontal motion from the rows around it.
|
|
386
|
+
let inHorizontalScroller = false;
|
|
382
387
|
for (let el: HTMLElement | null = target; el && el !== this.host.root; el = el.parentElement) {
|
|
383
388
|
const id = Number(el.dataset[ID_ATTR]);
|
|
389
|
+
if (isHorizontalScroller(el)) inHorizontalScroller = true;
|
|
384
390
|
if (el.classList.contains('sb-gesture') && Number.isInteger(id)) {
|
|
385
391
|
const node = this.host.nodeOf(id);
|
|
386
392
|
const props = node?.props ?? {};
|
|
@@ -392,7 +398,7 @@ export class InteractionController {
|
|
|
392
398
|
out.push({ kind: 'gesture', el, id, drag, longPress, minDistance, minDuration });
|
|
393
399
|
} else if (el.classList.contains('sb-contextmenu') && Number.isInteger(id)) {
|
|
394
400
|
out.push({ kind: 'contextmenu', el, id });
|
|
395
|
-
} else if (el.classList.contains('sb-swipeactions') || el.classList.contains('sb-listrow')) {
|
|
401
|
+
} else if ((el.classList.contains('sb-swipeactions') || el.classList.contains('sb-listrow')) && !inHorizontalScroller) {
|
|
396
402
|
const host = this.swipeHostFor(el);
|
|
397
403
|
if (host) out.push({ kind: 'swipe', host });
|
|
398
404
|
}
|
|
@@ -863,6 +869,10 @@ export class InteractionController {
|
|
|
863
869
|
const rowEl = target.closest<HTMLElement>('.sb-swipeactions, .sb-listrow');
|
|
864
870
|
const host = rowEl ? this.swipeHostFor(rowEl) : null;
|
|
865
871
|
if (!host) return;
|
|
872
|
+
// A horizontal scroll view in the row scrolls instead.
|
|
873
|
+
for (let el: HTMLElement | null = target; el && el !== rowEl; el = el.parentElement) {
|
|
874
|
+
if (isHorizontalScroller(el)) return;
|
|
875
|
+
}
|
|
866
876
|
let state = this.wheelSwipe;
|
|
867
877
|
if (!state || state.host.el !== host.el) {
|
|
868
878
|
if (state) this.finishWheelSwipe();
|
|
@@ -1077,6 +1087,13 @@ function clearSelectionIn(root: HTMLElement): void {
|
|
|
1077
1087
|
if (anchor && root.contains(anchor)) selection.removeAllRanges();
|
|
1078
1088
|
}
|
|
1079
1089
|
|
|
1090
|
+
/** A scroll view that scrolls horizontally (`ScrollView(.horizontal)` or both axes). */
|
|
1091
|
+
function isHorizontalScroller(el: HTMLElement): boolean {
|
|
1092
|
+
if (!el.classList.contains('sb-scrollview')) return false;
|
|
1093
|
+
const axes = el.dataset['sbAxes'];
|
|
1094
|
+
return axes === 'horizontal' || axes === 'both';
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1080
1097
|
/** The content slot of a swipe row (`swipeactions` / `listrow`). */
|
|
1081
1098
|
function contentOf(rowEl: HTMLElement): HTMLElement | null {
|
|
1082
1099
|
return rowEl.querySelector<HTMLElement>(':scope > .sb-swipe-content');
|
package/src/landing.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { toString as qrToString } from 'qrcode';
|
|
12
12
|
|
|
13
|
-
const REPO_URL = 'https://github.com/
|
|
13
|
+
const REPO_URL = 'https://github.com/noya-app/SwiftBrowser';
|
|
14
14
|
|
|
15
15
|
/** The card's subtitle: the spec's description, else a generic line. */
|
|
16
16
|
export function describeApp(app: Pick<SBLandingApp, 'name' | 'description'>): string {
|
|
@@ -132,7 +132,7 @@ function renderBuildInfo(): void {
|
|
|
132
132
|
footer.append(time);
|
|
133
133
|
}
|
|
134
134
|
footer.append(' · ');
|
|
135
|
-
const repo = el('a', 'build-repo', '
|
|
135
|
+
const repo = el('a', 'build-repo', 'noya-app/SwiftBrowser');
|
|
136
136
|
repo.href = REPO_URL;
|
|
137
137
|
footer.append(repo);
|
|
138
138
|
}
|