@swiftbrowser/web 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +141 -63
  2. package/app/index.html +21 -10
  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 +12 -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 +68 -3
  69. package/src/landing.ts +2 -2
  70. package/src/largeTitle.ts +79 -0
  71. package/src/layout/chart.ts +9 -3
  72. package/src/layout/engine.ts +681 -174
  73. package/src/layout/index.ts +3 -1
  74. package/src/layout/text.ts +217 -1
  75. package/src/layout/types.ts +31 -0
  76. package/src/layout/typography.ts +24 -7
  77. package/src/layout/video.ts +24 -0
  78. package/src/layoutDom.ts +66 -6
  79. package/src/main.ts +127 -4
  80. package/src/media.ts +132 -0
  81. package/src/mock.ts +4 -3
  82. package/src/notifications.ts +109 -0
  83. package/src/pathShape.ts +156 -0
  84. package/src/protocol.ts +782 -13
  85. package/src/renderer.ts +603 -44
  86. package/src/richText.ts +65 -0
  87. package/src/scanner.ts +234 -0
  88. package/src/scrollTo.ts +123 -0
  89. package/src/styles.css +521 -100
  90. package/src/styles.ts +61 -0
  91. package/src/swiftData.ts +128 -0
  92. package/src/symbols.ts +658 -28
  93. package/src/translation.ts +126 -0
  94. package/src/video.ts +299 -0
  95. package/src/vite-env.d.ts +6 -0
  96. package/src/wasm.ts +227 -32
  97. package/src/webAuth.ts +267 -0
  98. package/src/webSocket.ts +148 -0
  99. package/src/zoneinfo.ts +269 -0
@@ -9,7 +9,10 @@
9
9
  * 2. the wrappers between the target and the root, innermost first: a
10
10
  * `gesture` (drag, long press), a `contextmenu` (long press) and a swipe
11
11
  * row (`swipeactions`, or a deletable `listrow` without one);
12
- * 3. the sheet card (drag down to dismiss);
12
+ * 3. the sheet card (drag down to dismiss): inside a vertical scroll view
13
+ * only while that view is at its top, as UIKit hands a sheet's scroll
14
+ * view's downward drag to the sheet there (a touch's first downward move
15
+ * is kept from scrolling natively so the pointer reaches the sheet);
13
16
  * 4. pull to refresh on a `refreshable` scroller at its top.
14
17
  *
15
18
  * Nothing owns the pointer at first. On each move the candidates are asked in
@@ -24,6 +27,9 @@
24
27
  * reach the renderer's click handler untouched, so controls inside gestures
25
28
  * and rows keep working.
26
29
  *
30
+ * A horizontal scroll view keeps horizontal motion (touch or wheel) from the
31
+ * swipe rows around it, as UIKit's inner scroll view wins over a row's swipe.
32
+ *
27
33
  * Horizontal wheel scrolling slides swipe rows and vertical wheel at the
28
34
  * top of a `refreshable` scroller pulls to refresh; touch moves are read
29
35
  * directly for pull to refresh (the browser cancels the pointer once it
@@ -185,6 +191,8 @@ export class InteractionController {
185
191
  private wheelPull: { scroller: HTMLElement; pulled: number; sent: boolean; timer: number } | null = null;
186
192
  /** Touch-driven pull (the browser cancels the pointer once the list scrolls). */
187
193
  private touchPull: { scroller: HTMLElement; id: ElementId; startY: number; sent: boolean } | null = null;
194
+ /** A touch in a sheet's scroll view at its top: its first move decides who scrolls. */
195
+ private touchSheet: { scroller: HTMLElement; startY: number } | null = null;
188
196
 
189
197
  constructor(private readonly host: InteractionHost) {
190
198
  const root = host.root;
@@ -195,6 +203,7 @@ export class InteractionController {
195
203
  root.addEventListener('scroll', this.onScroll, true);
196
204
  root.addEventListener('touchstart', this.onTouchStart, { passive: true });
197
205
  root.addEventListener('touchmove', this.onTouchMove, { passive: true });
206
+ root.addEventListener('touchmove', this.onSheetTouchMove, { passive: false });
198
207
  root.addEventListener('touchend', this.onTouchEnd, { passive: true });
199
208
  root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
200
209
  document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
@@ -209,6 +218,7 @@ export class InteractionController {
209
218
  root.removeEventListener('scroll', this.onScroll, true);
210
219
  root.removeEventListener('touchstart', this.onTouchStart);
211
220
  root.removeEventListener('touchmove', this.onTouchMove);
221
+ root.removeEventListener('touchmove', this.onSheetTouchMove);
212
222
  root.removeEventListener('touchend', this.onTouchEnd);
213
223
  root.removeEventListener('touchcancel', this.onTouchEnd);
214
224
  document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
@@ -379,8 +389,11 @@ export class InteractionController {
379
389
  out.push({ kind: 'swipeback', screen, id, width: rect.width || screen.offsetWidth, below: below?.classList.contains('sb-navscreen') ? below : null });
380
390
  }
381
391
  }
392
+ // A horizontal scroll view takes horizontal motion from the rows around it.
393
+ let inHorizontalScroller = false;
382
394
  for (let el: HTMLElement | null = target; el && el !== this.host.root; el = el.parentElement) {
383
395
  const id = Number(el.dataset[ID_ATTR]);
396
+ if (isHorizontalScroller(el)) inHorizontalScroller = true;
384
397
  if (el.classList.contains('sb-gesture') && Number.isInteger(id)) {
385
398
  const node = this.host.nodeOf(id);
386
399
  const props = node?.props ?? {};
@@ -392,14 +405,16 @@ export class InteractionController {
392
405
  out.push({ kind: 'gesture', el, id, drag, longPress, minDistance, minDuration });
393
406
  } else if (el.classList.contains('sb-contextmenu') && Number.isInteger(id)) {
394
407
  out.push({ kind: 'contextmenu', el, id });
395
- } else if (el.classList.contains('sb-swipeactions') || el.classList.contains('sb-listrow')) {
408
+ } else if ((el.classList.contains('sb-swipeactions') || el.classList.contains('sb-listrow')) && !inHorizontalScroller) {
396
409
  const host = this.swipeHostFor(el);
397
410
  if (host) out.push({ kind: 'swipe', host });
398
411
  }
399
412
  }
400
413
  const card = target.closest<HTMLElement>('.sb-sheet-card');
401
414
  const sheet = card?.parentElement;
402
- if (card && sheet?.classList.contains('sb-sheet') && sheet.dataset['sbSheetPosition'] !== 'below') {
415
+ // A scroll view in the sheet that is scrolled down keeps the drag.
416
+ const sheetScroller = card ? verticalScrollerIn(target, card) : null;
417
+ if (card && sheet?.classList.contains('sb-sheet') && sheet.dataset['sbSheetPosition'] !== 'below' && !(sheetScroller && sheetScroller.scrollTop > 0)) {
403
418
  const id = Number(sheet.dataset[ID_ATTR]);
404
419
  const detents = (sheet.dataset['sbDetents'] ?? '').split(' ');
405
420
  // A full-screen cover (`detents: ["full"]`) has no drag to dismiss.
@@ -863,6 +878,10 @@ export class InteractionController {
863
878
  const rowEl = target.closest<HTMLElement>('.sb-swipeactions, .sb-listrow');
864
879
  const host = rowEl ? this.swipeHostFor(rowEl) : null;
865
880
  if (!host) return;
881
+ // A horizontal scroll view in the row scrolls instead.
882
+ for (let el: HTMLElement | null = target; el && el !== rowEl; el = el.parentElement) {
883
+ if (isHorizontalScroller(el)) return;
884
+ }
866
885
  let state = this.wheelSwipe;
867
886
  if (!state || state.host.el !== host.el) {
868
887
  if (state) this.finishWheelSwipe();
@@ -936,7 +955,15 @@ export class InteractionController {
936
955
 
937
956
  private readonly onTouchStart = (event: TouchEvent): void => {
938
957
  const touch = event.touches[0];
958
+ this.touchSheet = null;
939
959
  if (!touch || event.touches.length !== 1) return;
960
+ const target = event.target as HTMLElement;
961
+ const card = target.closest<HTMLElement>('.sb-sheet-card');
962
+ const sheetScroller = card ? verticalScrollerIn(target, card) : null;
963
+ // A refreshable scroller keeps its pull to refresh.
964
+ if (sheetScroller && sheetScroller.scrollTop <= 0 && sheetScroller.dataset['sbRefreshable'] === undefined) {
965
+ this.touchSheet = { scroller: sheetScroller, startY: touch.clientY };
966
+ }
940
967
  const scroller = this.refreshableScrollerOf(event.target as Element);
941
968
  if (!scroller || scroller.el.scrollTop > 0) return;
942
969
  this.touchPull = { scroller: scroller.el, id: scroller.id, startY: touch.clientY, sent: false };
@@ -959,6 +986,25 @@ export class InteractionController {
959
986
 
960
987
  private readonly onTouchEnd = (): void => {
961
988
  this.touchPull = null;
989
+ this.touchSheet = null;
990
+ };
991
+
992
+ /**
993
+ * The first move of a touch in a sheet's scroll view at its top: downwards,
994
+ * it is kept from scrolling (or overscrolling) the view, so the pointer
995
+ * stays with the sheet's drag; upwards, the view scrolls as usual.
996
+ */
997
+ private readonly onSheetTouchMove = (event: TouchEvent): void => {
998
+ const pending = this.touchSheet;
999
+ const touch = event.touches[0];
1000
+ if (!pending || !touch) return;
1001
+ const dy = touch.clientY - pending.startY;
1002
+ if (dy === 0) return;
1003
+ if (dy > 0 && pending.scroller.scrollTop <= 0 && event.cancelable) {
1004
+ event.preventDefault();
1005
+ return; // keep deciding: every move of this drag stays with the sheet
1006
+ }
1007
+ this.touchSheet = null;
962
1008
  };
963
1009
 
964
1010
  private readonly onContextMenu = (event: MouseEvent): void => {
@@ -1077,6 +1123,25 @@ function clearSelectionIn(root: HTMLElement): void {
1077
1123
  if (anchor && root.contains(anchor)) selection.removeAllRanges();
1078
1124
  }
1079
1125
 
1126
+ /** A scroll view that scrolls horizontally (`ScrollView(.horizontal)` or both axes). */
1127
+ /** The innermost vertically scrolling `list` / `scrollview` from `target` up to `limit` (excluded). */
1128
+ function verticalScrollerIn(target: HTMLElement, limit: HTMLElement): HTMLElement | null {
1129
+ for (let el: HTMLElement | null = target; el && el !== limit; el = el.parentElement) {
1130
+ if (el.classList.contains('sb-list')) return el;
1131
+ if (el.classList.contains('sb-scrollview')) {
1132
+ const axes = el.dataset['sbAxes'];
1133
+ if (axes === 'vertical' || axes === 'both' || axes === undefined) return el;
1134
+ }
1135
+ }
1136
+ return null;
1137
+ }
1138
+
1139
+ function isHorizontalScroller(el: HTMLElement): boolean {
1140
+ if (!el.classList.contains('sb-scrollview')) return false;
1141
+ const axes = el.dataset['sbAxes'];
1142
+ return axes === 'horizontal' || axes === 'both';
1143
+ }
1144
+
1080
1145
  /** The content slot of a swipe row (`swipeactions` / `listrow`). */
1081
1146
  function contentOf(rowEl: HTMLElement): HTMLElement | null {
1082
1147
  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
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A navigation screen's large title scrolls with the screen's root list or
3
+ * scroll view (iOS): the engine starts that scroll view under the bar, insets
4
+ * its content by the title row and marks it with a `largeTitle` chrome rect
5
+ * (layout/engine.ts, `placeNavScreen`). Here the title follows the scroll
6
+ * offset and fades out at the bar's edge, as the scrolled content does
7
+ * (`data-sb-scrolled`, iOS 26's scroll edge effect), and once it has gone
8
+ * under the bar the screen gets `data-sb-collapsed`, which shows the inline
9
+ * title (styles.css). Pulled past the top (a touch overscroll), the title
10
+ * moves down with the content (iOS 26 no longer grows it).
11
+ */
12
+
13
+ /** The scroll offset past which the title has gone under the bar. */
14
+ const COLLAPSE_OFFSET = 44;
15
+ /** How far below the bar's edge content fades in (styles.css matches). */
16
+ const EDGE_FADE = 16;
17
+
18
+ const bound = new WeakSet<HTMLElement>();
19
+
20
+ function largeTitleOf(scroller: HTMLElement): { screen: HTMLElement; title: HTMLElement } | null {
21
+ const screen = scroller.closest<HTMLElement>('.sb-navscreen');
22
+ const title = screen?.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
23
+ return screen && title ? { screen, title } : null;
24
+ }
25
+
26
+ /**
27
+ * Called for every list and scroll view the layout reaches: `scrolls` is
28
+ * whether the engine marked it as carrying its screen's large title.
29
+ */
30
+ export function bindLargeTitle(scroller: HTMLElement, scrolls: boolean): void {
31
+ if (!scrolls) {
32
+ if (scroller.dataset['sbScrollsTitle'] !== undefined) {
33
+ delete scroller.dataset['sbScrollsTitle'];
34
+ delete scroller.dataset['sbScrolled'];
35
+ const parts = largeTitleOf(scroller);
36
+ if (parts) reset(parts.screen, parts.title);
37
+ }
38
+ return;
39
+ }
40
+ scroller.dataset['sbScrollsTitle'] = '';
41
+ if (!bound.has(scroller)) {
42
+ bound.add(scroller);
43
+ scroller.addEventListener('scroll', () => syncLargeTitle(scroller), { passive: true });
44
+ }
45
+ syncLargeTitle(scroller);
46
+ }
47
+
48
+ export function syncLargeTitle(scroller: HTMLElement): void {
49
+ if (scroller.dataset['sbScrollsTitle'] === undefined) return;
50
+ const parts = largeTitleOf(scroller);
51
+ if (!parts) return;
52
+ const { screen, title } = parts;
53
+ const offset = scroller.scrollTop;
54
+ const s = title.style;
55
+ if (offset > 0) {
56
+ s.transform = `translateY(${-offset}px)`;
57
+ // Hide what has gone above the title's row (under the bar), fading in
58
+ // below the edge.
59
+ setMask(title, `linear-gradient(to bottom, transparent ${offset}px, #000 ${offset + EDGE_FADE}px)`);
60
+ scroller.dataset['sbScrolled'] = '';
61
+ } else {
62
+ s.transform = offset < 0 ? `translateY(${-offset}px)` : '';
63
+ setMask(title, '');
64
+ delete scroller.dataset['sbScrolled'];
65
+ }
66
+ if (offset > COLLAPSE_OFFSET) screen.dataset['sbCollapsed'] = '';
67
+ else delete screen.dataset['sbCollapsed'];
68
+ }
69
+
70
+ function setMask(el: HTMLElement, mask: string): void {
71
+ el.style.setProperty('mask-image', mask);
72
+ el.style.setProperty('-webkit-mask-image', mask);
73
+ }
74
+
75
+ function reset(screen: HTMLElement, title: HTMLElement): void {
76
+ title.style.transform = '';
77
+ setMask(title, '');
78
+ delete screen.dataset['sbCollapsed'];
79
+ }
@@ -14,6 +14,8 @@ export const CHART_DEFAULT_WIDTH = 300;
14
14
  export const CHART_DEFAULT_HEIGHT = 200;
15
15
  /** Gap between an axis label and the plot edge (the ticks sit inside it). */
16
16
  export const CHART_AXIS_PAD = 6;
17
+ /** How far a leading-anchored x label (a date's) starts after its tick. */
18
+ export const CHART_LEADING_LABEL_OFFSET = 2;
17
19
  export const CHART_TICK_LENGTH = 4;
18
20
  /** Legend glyph: a `symbolSize`-px symbol, or an 18 × 3 line when the item has no symbol. */
19
21
  export const CHART_LEGEND_GLYPH = 10;
@@ -32,8 +34,11 @@ export type ChartItemPosition = 'top' | 'bottom' | 'leading' | 'trailing' | 'ove
32
34
 
33
35
  export interface ChartAxisInput {
34
36
  position: ChartAxisPosition;
35
- /** Text labels: unit position and measured width (height is `labelHeight`). */
36
- labels: { at: number; width: number }[];
37
+ /**
38
+ * Text labels: unit position and measured width (height is `labelHeight`);
39
+ * `leading`: an x label starts at its tick (a date's) instead of centering on it.
40
+ */
41
+ labels: { at: number; width: number; leading?: boolean }[];
37
42
  /** Line height of the axis font. */
38
43
  labelHeight: number;
39
44
  /** View labels (`xLabel` / `yLabel` children) at their ideal size. */
@@ -186,7 +191,8 @@ export function layoutChart(input: ChartLayoutInput): ChartGeometry {
186
191
  const top = (h: number) => (xTop ? plot.y - CHART_AXIS_PAD - h : plot.y + plot.height + CHART_AXIS_PAD);
187
192
  for (const label of xAxis.labels) {
188
193
  const h = xAxis.labelHeight;
189
- const x = clampTo(mapX(plot, label.at) - label.width / 2, label.width, 0, width);
194
+ const start = label.leading ? mapX(plot, label.at) + CHART_LEADING_LABEL_OFFSET : mapX(plot, label.at) - label.width / 2;
195
+ const x = clampTo(start, label.width, 0, width);
190
196
  xLabels.push({ x, y: top(h), width: label.width, height: h });
191
197
  }
192
198
  for (const view of xAxis.views) {