@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.
Files changed (98) hide show
  1. package/README.md +43 -6
  2. package/app/index.html +11 -0
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +11 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +18 -1
  69. package/src/landing.ts +2 -2
  70. package/src/layout/chart.ts +9 -3
  71. package/src/layout/engine.ts +279 -25
  72. package/src/layout/index.ts +3 -1
  73. package/src/layout/text.ts +217 -1
  74. package/src/layout/types.ts +31 -0
  75. package/src/layout/typography.ts +24 -7
  76. package/src/layout/video.ts +24 -0
  77. package/src/layoutDom.ts +53 -3
  78. package/src/main.ts +124 -4
  79. package/src/media.ts +132 -0
  80. package/src/mock.ts +2 -1
  81. package/src/notifications.ts +109 -0
  82. package/src/pathShape.ts +156 -0
  83. package/src/protocol.ts +761 -12
  84. package/src/renderer.ts +584 -36
  85. package/src/richText.ts +65 -0
  86. package/src/scanner.ts +234 -0
  87. package/src/scrollTo.ts +123 -0
  88. package/src/styles.css +212 -7
  89. package/src/styles.ts +31 -0
  90. package/src/swiftData.ts +128 -0
  91. package/src/symbols.ts +564 -21
  92. package/src/translation.ts +126 -0
  93. package/src/video.ts +299 -0
  94. package/src/vite-env.d.ts +6 -0
  95. package/src/wasm.ts +227 -32
  96. package/src/webAuth.ts +267 -0
  97. package/src/webSocket.ts +148 -0
  98. package/src/zoneinfo.ts +269 -0
@@ -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
+ }
@@ -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/dabbott/SwiftBrowser';
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', 'dabbott/SwiftBrowser');
135
+ const repo = el('a', 'build-repo', 'noya-app/SwiftBrowser');
136
136
  repo.href = REPO_URL;
137
137
  footer.append(repo);
138
138
  }