@swiftbrowser/web 0.26.1 → 0.26.2
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/package.json +1 -1
- package/src/pathShape.ts +42 -7
- package/src/protocol.ts +30 -1
- package/src/renderer.ts +13 -1
package/package.json
CHANGED
package/src/pathShape.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* `path(in:)`. The Swift side sends the path as SVG path data for the size the
|
|
4
4
|
* element reported (`reportsSize`), so the element holds an `<svg>` whose
|
|
5
5
|
* view box is that size, stretched to the element's box until the path for a
|
|
6
|
-
* new size arrives (docs/ops-protocol.md, "Custom shapes").
|
|
6
|
+
* new size arrives (docs/ops-protocol.md, "Custom shapes"). A path for any
|
|
7
|
+
* size (`affinePath`) is drawn at the size the element is laid out at, which
|
|
8
|
+
* the renderer hands `layOutAffinePath` after each layout.
|
|
7
9
|
*
|
|
8
10
|
* An update whose shape's `animatableData` changed carries `frames`: the
|
|
9
11
|
* paths at fractions of the change. When the commit animates the element,
|
|
@@ -12,7 +14,7 @@
|
|
|
12
14
|
* path; otherwise the final path is drawn at once.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
|
-
import type { Animation, ShapeProps } from './protocol';
|
|
17
|
+
import type { AffinePath, Animation, ShapeProps } from './protocol';
|
|
16
18
|
import { paintFor, type IdSource } from './chartDom';
|
|
17
19
|
import { easedProgress } from './animation';
|
|
18
20
|
|
|
@@ -32,6 +34,9 @@ interface PathState {
|
|
|
32
34
|
frames: PathFrames | null;
|
|
33
35
|
/** The running playback's frame request. */
|
|
34
36
|
request: number | null;
|
|
37
|
+
/** The props last drawn, and the size an `affinePath` was last laid out at (src/renderer.ts). */
|
|
38
|
+
props: ShapeProps | null;
|
|
39
|
+
laidOut: { width: number; height: number } | null;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
const STATE = new WeakMap<HTMLElement, PathState>();
|
|
@@ -48,13 +53,17 @@ export function applyPathShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
48
53
|
const path = doc.createElementNS(SVG_NS, 'path');
|
|
49
54
|
svg.append(doc.createElementNS(SVG_NS, 'defs'), path);
|
|
50
55
|
el.replaceChildren(svg);
|
|
51
|
-
state = { svg, path, final: '', frames: null, request: null };
|
|
56
|
+
state = { svg, path, final: '', frames: null, request: null, props: null, laidOut: null };
|
|
52
57
|
STATE.set(el, state);
|
|
53
58
|
}
|
|
54
59
|
stop(state);
|
|
60
|
+
state.props = props;
|
|
55
61
|
const { svg, path } = state;
|
|
56
|
-
|
|
57
|
-
const
|
|
62
|
+
// A path for any size is drawn at the size the element was laid out at (none before its first layout).
|
|
63
|
+
const affine = props.affinePath ?? null;
|
|
64
|
+
const size = affine ? state.laidOut : props.size;
|
|
65
|
+
const width = Number(size?.width) || 0;
|
|
66
|
+
const height = Number(size?.height) || 0;
|
|
58
67
|
if (width > 0 && height > 0) svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
59
68
|
else svg.removeAttribute('viewBox');
|
|
60
69
|
svg.setAttribute('preserveAspectRatio', 'none');
|
|
@@ -81,11 +90,37 @@ export function applyPathShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
81
90
|
// The stroke is not scaled with a stretched view box.
|
|
82
91
|
path.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
83
92
|
|
|
84
|
-
state.final = typeof props.path === 'string' ? props.path : '';
|
|
85
|
-
state.frames = validFrames(props.frames);
|
|
93
|
+
state.final = affine ? (width > 0 && height > 0 ? affinePathData(affine, width, height) : '') : typeof props.path === 'string' ? props.path : '';
|
|
94
|
+
state.frames = affine ? null : validFrames(props.frames);
|
|
86
95
|
path.setAttribute('d', state.final);
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Draws `el`'s `affinePath` (if it has one) at the `width` by `height` it was
|
|
100
|
+
* laid out at, when that is new: the renderer calls it after each layout.
|
|
101
|
+
*/
|
|
102
|
+
export function layOutAffinePath(el: HTMLElement, width: number, height: number): void {
|
|
103
|
+
const state = STATE.get(el);
|
|
104
|
+
if (!state?.props?.affinePath) return;
|
|
105
|
+
if (state.laidOut && state.laidOut.width === width && state.laidOut.height === height) return;
|
|
106
|
+
state.laidOut = { width, height };
|
|
107
|
+
applyPathShape(el, state.props);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A number in SVG path data, as `Path` (@swiftbrowser/react) writes them. */
|
|
111
|
+
const NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/g;
|
|
112
|
+
|
|
113
|
+
/** `affine`'s SVG path data for a `width` by `height` rectangle, its points rounded to hundredths as `Path` rounds them. */
|
|
114
|
+
export function affinePathData(affine: AffinePath, width: number, height: number): string {
|
|
115
|
+
let i = 0;
|
|
116
|
+
return affine.path.replace(NUMBER, (number) => {
|
|
117
|
+
const value = Number(number) + width * (affine.width[i] ?? 0) + height * (affine.height[i] ?? 0);
|
|
118
|
+
i += 1;
|
|
119
|
+
const rounded = Math.round(value * 100) / 100;
|
|
120
|
+
return Object.is(rounded, -0) ? '0' : String(rounded);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
89
124
|
/** Whether `el` is a path shape whose last update carried frames. */
|
|
90
125
|
export function hasPathFrames(el: HTMLElement): boolean {
|
|
91
126
|
return STATE.get(el)?.frames != null;
|
package/src/protocol.ts
CHANGED
|
@@ -728,6 +728,13 @@ export interface Stroke {
|
|
|
728
728
|
dash?: number[];
|
|
729
729
|
dashPhase?: number;
|
|
730
730
|
}
|
|
731
|
+
/** A path for any size: `ShapeProps.affinePath`. */
|
|
732
|
+
export interface AffinePath {
|
|
733
|
+
path: string;
|
|
734
|
+
width: number[];
|
|
735
|
+
height: number[];
|
|
736
|
+
}
|
|
737
|
+
|
|
731
738
|
/** `fill: null` means the current foreground color; fills its proposal. */
|
|
732
739
|
export interface ShapeProps {
|
|
733
740
|
shape: ShapeKind;
|
|
@@ -748,6 +755,14 @@ export interface ShapeProps {
|
|
|
748
755
|
*/
|
|
749
756
|
path?: string;
|
|
750
757
|
size?: { width: number; height: number };
|
|
758
|
+
/**
|
|
759
|
+
* A custom shape's path for any size (`path` and `size` unused): SVG path
|
|
760
|
+
* data whose numbers are their values for an empty rectangle, the `i`th
|
|
761
|
+
* growing `width[i]` a point of the element's width and `height[i]` a
|
|
762
|
+
* point of its height. Drawn at the element's laid-out size, so from its
|
|
763
|
+
* first layout, with no size reported.
|
|
764
|
+
*/
|
|
765
|
+
affinePath?: AffinePath;
|
|
751
766
|
/** `FillStyle(eoFill: true)`: overlapping parts of the path cancel out. */
|
|
752
767
|
fillRule?: 'evenodd';
|
|
753
768
|
/** The element reports its laid-out size (a custom shape draws its path for it). */
|
|
@@ -1572,7 +1587,21 @@ export type RendererEvent =
|
|
|
1572
1587
|
// The page's undo and redo keys, for the app's undo manager (src/undo.ts)
|
|
1573
1588
|
| UndoEvent
|
|
1574
1589
|
// A registered view's own answer (src/viewRegistry.ts)
|
|
1575
|
-
| RegisteredEvent
|
|
1590
|
+
| RegisteredEvent
|
|
1591
|
+
// A stack's path set whole by the tree that hosts this one (@swiftbrowser/react RemoteTree)
|
|
1592
|
+
| SetPathEvent;
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* A `navstack`'s path set whole: the values its destinations build the
|
|
1596
|
+
* screens from, as its pushed `navscreen`s report them (`route`). Sent by a
|
|
1597
|
+
* host drawing this tree (`RemoteTree`), to reopen the screens a link named;
|
|
1598
|
+
* never by a renderer.
|
|
1599
|
+
*/
|
|
1600
|
+
export interface SetPathEvent {
|
|
1601
|
+
type: 'setpath';
|
|
1602
|
+
id: ElementId;
|
|
1603
|
+
path: unknown[];
|
|
1604
|
+
}
|
|
1576
1605
|
|
|
1577
1606
|
/** What a registered view sent (`context.send(value)`), for the element's `onEvent`. */
|
|
1578
1607
|
export interface RegisteredEvent {
|
package/src/renderer.ts
CHANGED
|
@@ -160,7 +160,7 @@ import {
|
|
|
160
160
|
timeInputValue,
|
|
161
161
|
} from './layout';
|
|
162
162
|
import { filledSymbolName, renderSymbol } from './symbols';
|
|
163
|
-
import { applyPathShape, hasPathFrames, playPathFrames } from './pathShape';
|
|
163
|
+
import { applyPathShape, hasPathFrames, layOutAffinePath, playPathFrames } from './pathShape';
|
|
164
164
|
import { VideoEvents, applyVideo, stopVideos } from './video';
|
|
165
165
|
import { loadableURL } from './hostRequests';
|
|
166
166
|
import { PinchController } from './pinch';
|
|
@@ -494,6 +494,8 @@ export class Renderer {
|
|
|
494
494
|
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
|
|
495
495
|
/** The elements whose size is reported (`geometry`, or `reportsSize` props): what `collectGeometry` visits. */
|
|
496
496
|
private readonly geometryReporters = new Set<ElementId>();
|
|
497
|
+
/** The custom shapes with a path for any size (`affinePath`), drawn at their size after each layout. */
|
|
498
|
+
private readonly affinePaths = new Set<ElementId>();
|
|
497
499
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
498
500
|
private pendingGeometry: (GeometryEvent | LayoutEvent | ScrollGeometryEvent)[] = [];
|
|
499
501
|
/** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
|
|
@@ -1451,6 +1453,7 @@ export class Renderer {
|
|
|
1451
1453
|
this.closeMenu();
|
|
1452
1454
|
this.reportedGeometry.clear();
|
|
1453
1455
|
this.geometryReporters.clear();
|
|
1456
|
+
this.affinePaths.clear();
|
|
1454
1457
|
this.layoutReporter.clear();
|
|
1455
1458
|
this.pendingGeometry = [];
|
|
1456
1459
|
refreshSheets(this.root);
|
|
@@ -1786,6 +1789,11 @@ export class Renderer {
|
|
|
1786
1789
|
}
|
|
1787
1790
|
drawScrollTransitions();
|
|
1788
1791
|
drawPinnedViews();
|
|
1792
|
+
for (const id of this.affinePaths) {
|
|
1793
|
+
const frame = result.frames.get(id);
|
|
1794
|
+
const el = this.elements.get(id);
|
|
1795
|
+
if (frame && el) layOutAffinePath(el, frame.width, frame.height);
|
|
1796
|
+
}
|
|
1789
1797
|
this.collectGeometry(result);
|
|
1790
1798
|
this.collectScrollGeometry(result);
|
|
1791
1799
|
this.collectLayouts(result);
|
|
@@ -2119,6 +2127,7 @@ export class Renderer {
|
|
|
2119
2127
|
this.elements.set(id, el);
|
|
2120
2128
|
this.nodes.set(id, { id, kind, props: asRecord(props), children: [] });
|
|
2121
2129
|
if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
|
|
2130
|
+
if ((props as { affinePath?: unknown }).affinePath) this.affinePaths.add(id);
|
|
2122
2131
|
applyProps(el, kind, props, this.assets);
|
|
2123
2132
|
this.noteScroll(id, kind, props);
|
|
2124
2133
|
this.noteVisibility(id, kind, props);
|
|
@@ -2529,6 +2538,8 @@ export class Renderer {
|
|
|
2529
2538
|
} else {
|
|
2530
2539
|
this.geometryReporters.delete(id);
|
|
2531
2540
|
}
|
|
2541
|
+
if (next['affinePath']) this.affinePaths.add(id);
|
|
2542
|
+
else this.affinePaths.delete(id);
|
|
2532
2543
|
node.props = next;
|
|
2533
2544
|
// Phase 9: the next layout re-measures this subtree and its ancestors;
|
|
2534
2545
|
// a screen's title also feeds the next screen's back label.
|
|
@@ -2814,6 +2825,7 @@ export class Renderer {
|
|
|
2814
2825
|
this.scrollGeometry.forget(id);
|
|
2815
2826
|
this.offsetSerials.delete(id);
|
|
2816
2827
|
this.geometryReporters.delete(id);
|
|
2828
|
+
this.affinePaths.delete(id);
|
|
2817
2829
|
this.layoutReporter.forget(id);
|
|
2818
2830
|
const node = this.nodes.get(id);
|
|
2819
2831
|
const parentId = this.parents.get(id);
|