@caperjs/solid 0.7.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 +191 -0
- package/jsx.d.ts +39 -0
- package/lib/AnimatedShow.d.ts +25 -0
- package/lib/AnimatedShow.d.ts.map +1 -0
- package/lib/AnimatedShow.test.d.ts +2 -0
- package/lib/AnimatedShow.test.d.ts.map +1 -0
- package/lib/Composable.d.ts +39 -0
- package/lib/Composable.d.ts.map +1 -0
- package/lib/Composable.test.d.ts +2 -0
- package/lib/Composable.test.d.ts.map +1 -0
- package/lib/animated.d.ts +16 -0
- package/lib/animated.d.ts.map +1 -0
- package/lib/animated.test.d.ts +2 -0
- package/lib/animated.test.d.ts.map +1 -0
- package/lib/asComponent.d.ts +19 -0
- package/lib/asComponent.d.ts.map +1 -0
- package/lib/asComponent.test.d.ts +2 -0
- package/lib/asComponent.test.d.ts.map +1 -0
- package/lib/caper-plugin-solid.mjs +337 -0
- package/lib/caper-plugin-solid.mjs.map +1 -0
- package/lib/catalog.d.ts +17 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/renderer.d.ts +4 -0
- package/lib/renderer.d.ts.map +1 -0
- package/lib/renderer.test.d.ts +2 -0
- package/lib/renderer.test.d.ts.map +1 -0
- package/lib/useTick.d.ts +4 -0
- package/lib/useTick.d.ts.map +1 -0
- package/lib/useTick.test.d.ts +2 -0
- package/lib/useTick.test.d.ts.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/AnimatedShow.test.tsx +146 -0
- package/src/AnimatedShow.tsx +87 -0
- package/src/Composable.test.ts +214 -0
- package/src/Composable.ts +84 -0
- package/src/animated.test.ts +94 -0
- package/src/animated.ts +39 -0
- package/src/asComponent.test.ts +168 -0
- package/src/asComponent.ts +37 -0
- package/src/catalog.ts +28 -0
- package/src/index.ts +11 -0
- package/src/renderer.test.ts +272 -0
- package/src/renderer.ts +155 -0
- package/src/useTick.test.ts +28 -0
- package/src/useTick.ts +10 -0
- package/src/version.ts +1 -0
- package/vite.d.mts +10 -0
- package/vite.mjs +25 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Application, Container } from '@caperjs/core';
|
|
2
|
+
import { Graphics, Sprite } from 'pixi.js';
|
|
3
|
+
import { createSignal } from 'solid-js';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { asComponent } from './asComponent';
|
|
7
|
+
import { ComposableContainer, type Composes } from './Composable';
|
|
8
|
+
import { createElement, type PixiNode, render } from './renderer';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Caper's `Container` reaches for the Application singleton on `added`,
|
|
12
|
+
* `removed` and `destroy`. Only the members those paths touch are stubbed.
|
|
13
|
+
*/
|
|
14
|
+
function stubApplication() {
|
|
15
|
+
const ticker = { add: vi.fn(), remove: vi.fn(), addOnce: vi.fn() };
|
|
16
|
+
const layout = { update: vi.fn(), _updateSize: vi.fn(), updateLayout: vi.fn() };
|
|
17
|
+
const onResize = { connect: vi.fn(() => ({ disconnect: vi.fn() })), disconnect: vi.fn() };
|
|
18
|
+
(Application as any).instance = {
|
|
19
|
+
config: { useLayout: true },
|
|
20
|
+
ticker,
|
|
21
|
+
onResize,
|
|
22
|
+
animation: { killAll: vi.fn() },
|
|
23
|
+
renderer: { layout },
|
|
24
|
+
};
|
|
25
|
+
return { ticker, layout, onResize };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
stubApplication();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
(Application as any).instance = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Mount a component the way JSX would: into a host node, inside a solid root. */
|
|
37
|
+
function mount<T extends PixiNode>(component: () => T) {
|
|
38
|
+
const root = createElement('container');
|
|
39
|
+
const dispose = render(() => component(), root);
|
|
40
|
+
return { root, node: root.children[0] as T, dispose };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An ordinary imperative display class that knows nothing about Solid. */
|
|
44
|
+
class Widget extends Container {}
|
|
45
|
+
|
|
46
|
+
/** Builds its own look up front — the case `skipChildren` protects. */
|
|
47
|
+
class SelfBuilt extends Container {
|
|
48
|
+
constructor(config?: any) {
|
|
49
|
+
super(config);
|
|
50
|
+
this.add.graphics();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('asComponent', () => {
|
|
55
|
+
it('mounts the constructed instance and applies props', () => {
|
|
56
|
+
const Widget$ = asComponent(Widget);
|
|
57
|
+
const { root, node } = mount(() => Widget$({ x: 5, alpha: 0.5 }));
|
|
58
|
+
|
|
59
|
+
expect(root.children).toHaveLength(1);
|
|
60
|
+
expect(node).toBeInstanceOf(Widget);
|
|
61
|
+
expect(node.x).toBe(5);
|
|
62
|
+
expect(node.alpha).toBe(0.5);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps props reactive after mount', () => {
|
|
66
|
+
const [x, setX] = createSignal(0);
|
|
67
|
+
const Widget$ = asComponent(Widget);
|
|
68
|
+
const { node, dispose } = mount(() =>
|
|
69
|
+
Widget$({
|
|
70
|
+
get x() {
|
|
71
|
+
return x();
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(node.x).toBe(0);
|
|
77
|
+
setX(25);
|
|
78
|
+
expect(node.x).toBe(25);
|
|
79
|
+
|
|
80
|
+
dispose();
|
|
81
|
+
setX(99);
|
|
82
|
+
expect(node.x).toBe(25);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('constructs once, untracked — a signal read in the constructor is not a dependency', () => {
|
|
86
|
+
const [label, setLabel] = createSignal('one');
|
|
87
|
+
const built: string[] = [];
|
|
88
|
+
|
|
89
|
+
class Reader extends Container {
|
|
90
|
+
constructor() {
|
|
91
|
+
super();
|
|
92
|
+
built.push(label());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const Reader$ = asComponent(Reader);
|
|
97
|
+
const { node } = mount(() => Reader$({}));
|
|
98
|
+
|
|
99
|
+
setLabel('two');
|
|
100
|
+
|
|
101
|
+
expect(built).toEqual(['one']);
|
|
102
|
+
expect(node).toBeInstanceOf(Reader);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('hands the defaults to the constructor', () => {
|
|
106
|
+
let received: unknown;
|
|
107
|
+
|
|
108
|
+
class Configured extends Container {
|
|
109
|
+
constructor(config?: { label?: string }) {
|
|
110
|
+
super(config as any);
|
|
111
|
+
received = config;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const Configured$ = asComponent(Configured, { label: 'hp' });
|
|
116
|
+
mount(() => Configured$({ x: 1 }));
|
|
117
|
+
|
|
118
|
+
expect(received).toEqual({ label: 'hp' });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('calls a ref prop with the instance', () => {
|
|
122
|
+
let captured: Widget | undefined;
|
|
123
|
+
const Widget$ = asComponent(Widget);
|
|
124
|
+
const { node } = mount(() => Widget$({ ref: (el: Widget) => (captured = el) }));
|
|
125
|
+
|
|
126
|
+
expect(captured).toBe(node);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('children', () => {
|
|
130
|
+
it('leaves the class’s own children alone when no JSX children are passed', () => {
|
|
131
|
+
const SelfBuilt$ = asComponent(SelfBuilt);
|
|
132
|
+
const { node } = mount(() => SelfBuilt$({ x: 1 }));
|
|
133
|
+
|
|
134
|
+
// `skipChildren` is what keeps solid's children pass — an
|
|
135
|
+
// `insertExpression(node, undefined, …)` that ends in `cleanChildren` —
|
|
136
|
+
// away from what the class built for itself. (solid 1.9 happens to also
|
|
137
|
+
// bail out of that pass early when both values are `undefined`; the guard
|
|
138
|
+
// is what the contract rests on, not that detail.)
|
|
139
|
+
expect(node.children).toHaveLength(1);
|
|
140
|
+
expect(node.children[0]).toBeInstanceOf(Graphics);
|
|
141
|
+
expect(node.children[0].destroyed).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('inserts JSX children when they are passed', () => {
|
|
145
|
+
const Widget$ = asComponent(Widget);
|
|
146
|
+
const child = createElement('sprite');
|
|
147
|
+
const { node } = mount(() => Widget$({ children: child }));
|
|
148
|
+
|
|
149
|
+
expect(node.children).toEqual([child]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('still mounts a composed class’s own tree', () => {
|
|
154
|
+
class Bar extends ComposableContainer implements Composes {
|
|
155
|
+
public compose() {
|
|
156
|
+
return createElement('sprite');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const Bar$ = asComponent(Bar);
|
|
161
|
+
const { node } = mount(() => Bar$({ x: 3 }));
|
|
162
|
+
|
|
163
|
+
expect(node).toBeInstanceOf(Bar);
|
|
164
|
+
expect(node.x).toBe(3);
|
|
165
|
+
expect(node.children).toHaveLength(1);
|
|
166
|
+
expect(node.children[0]).toBeInstanceOf(Sprite);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Lifting existing imperative display classes into JSX — the escape hatch that
|
|
2
|
+
// keeps the intrinsic catalog small.
|
|
3
|
+
|
|
4
|
+
import { untrack } from 'solid-js';
|
|
5
|
+
|
|
6
|
+
import type { PixiNode } from './renderer';
|
|
7
|
+
import { spread } from './renderer';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lift an existing imperative display class into a JSX component.
|
|
11
|
+
*
|
|
12
|
+
* The instance is constructed once (untracked, so constructor reads don't
|
|
13
|
+
* subscribe to anything) and every prop is then applied through the renderer's
|
|
14
|
+
* own `spread`, which gives them the same reactivity as an intrinsic element —
|
|
15
|
+
* including `ref`.
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* const Orbiter$ = asComponent(Orbiter);
|
|
19
|
+
* <Orbiter$ x={40} y={80} />
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @param Ctor - The display class to lift. Called as `new Ctor(defaults)`.
|
|
23
|
+
* @param defaults - Config handed to the constructor on every mount.
|
|
24
|
+
*/
|
|
25
|
+
export function asComponent<TNode extends PixiNode, TConfig>(
|
|
26
|
+
Ctor: new (config?: TConfig) => TNode,
|
|
27
|
+
defaults?: TConfig,
|
|
28
|
+
): (props: Record<string, any>) => TNode {
|
|
29
|
+
return (props) => {
|
|
30
|
+
const node = untrack(() => new Ctor(defaults));
|
|
31
|
+
// Skip the children pass when none were passed — otherwise Solid's spread
|
|
32
|
+
// "clears" the node, which would wipe anything the class built for itself
|
|
33
|
+
// (in `added()`, its own `compose()`, or the constructor).
|
|
34
|
+
spread(node, props as any, !('children' in props));
|
|
35
|
+
return node;
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FlexContainer } from '@caperjs/core';
|
|
2
|
+
import { Container, Graphics, Sprite, Text } from 'pixi.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The intrinsic elements JSX can name. Every entry is a zero-arg-constructible
|
|
6
|
+
* Pixi display object; the renderer applies props to the instance afterwards.
|
|
7
|
+
*/
|
|
8
|
+
export const catalog = {
|
|
9
|
+
container: Container,
|
|
10
|
+
sprite: Sprite,
|
|
11
|
+
text: Text,
|
|
12
|
+
graphics: Graphics,
|
|
13
|
+
// Caper UI primitive. Its layout props (`gap`, `flexDirection`, …) are plain
|
|
14
|
+
// accessors that re-run its deferred layout pass, so `setProperty`'s default
|
|
15
|
+
// assignment is all it needs.
|
|
16
|
+
flexContainer: FlexContainer,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type CatalogTag = keyof typeof catalog;
|
|
20
|
+
|
|
21
|
+
/** Construct the display object for an intrinsic tag. Throws on unknown tags. */
|
|
22
|
+
export function createCatalogNode(tag: string): Container {
|
|
23
|
+
const Ctor = catalog[tag as CatalogTag] as unknown as (new () => Container) | undefined;
|
|
24
|
+
if (!Ctor) {
|
|
25
|
+
throw new Error(`[@caperjs/solid] Unknown element <${tag}>. Known elements: ${Object.keys(catalog).join(', ')}.`);
|
|
26
|
+
}
|
|
27
|
+
return new Ctor();
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { version } from './version';
|
|
2
|
+
|
|
3
|
+
export const VERSION = version;
|
|
4
|
+
|
|
5
|
+
export * from './animated';
|
|
6
|
+
export * from './AnimatedShow';
|
|
7
|
+
export * from './asComponent';
|
|
8
|
+
export * from './catalog';
|
|
9
|
+
export * from './Composable';
|
|
10
|
+
export * from './renderer';
|
|
11
|
+
export * from './useTick';
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { Application, FlexContainer } from '@caperjs/core';
|
|
2
|
+
import { Container, Graphics, Sprite, Text } from 'pixi.js';
|
|
3
|
+
import { createRoot, createSignal } from 'solid-js';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { catalog } from './catalog';
|
|
7
|
+
import { createElement, createTextNode, insert, insertNode, render, setProp, spread } from './renderer';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* FlexContainer and the flex re-measure workaround both read the Application
|
|
11
|
+
* singleton. Only the handful of members they touch are stubbed.
|
|
12
|
+
*/
|
|
13
|
+
function stubApplication() {
|
|
14
|
+
const ticker = { add: vi.fn(), remove: vi.fn(), addOnce: vi.fn() };
|
|
15
|
+
const layout = { update: vi.fn(), _updateSize: vi.fn(), updateLayout: vi.fn() };
|
|
16
|
+
(Application as any).instance = {
|
|
17
|
+
config: { useLayout: true },
|
|
18
|
+
ticker,
|
|
19
|
+
renderer: { layout },
|
|
20
|
+
};
|
|
21
|
+
return { ticker, layout };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let app: ReturnType<typeof stubApplication>;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
app = stubApplication();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
(Application as any).instance = undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('createElement', () => {
|
|
35
|
+
it('constructs every catalog tag', () => {
|
|
36
|
+
expect(createElement('container')).toBeInstanceOf(Container);
|
|
37
|
+
expect(createElement('sprite')).toBeInstanceOf(Sprite);
|
|
38
|
+
expect(createElement('text')).toBeInstanceOf(Text);
|
|
39
|
+
expect(createElement('graphics')).toBeInstanceOf(Graphics);
|
|
40
|
+
expect(createElement('flexContainer')).toBeInstanceOf(FlexContainer);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws on an unknown tag, listing the known ones', () => {
|
|
44
|
+
expect(() => createElement('nope')).toThrow(/Unknown element <nope>/);
|
|
45
|
+
for (const tag of Object.keys(catalog)) {
|
|
46
|
+
expect(() => createElement('nope')).toThrow(new RegExp(tag));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('creates text with events off', () => {
|
|
51
|
+
expect(createElement('text').eventMode).toBe('none');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('insertNode / removeNode', () => {
|
|
56
|
+
it('appends when there is no anchor', () => {
|
|
57
|
+
const parent = createElement('container');
|
|
58
|
+
const a = createElement('sprite');
|
|
59
|
+
const b = createElement('sprite');
|
|
60
|
+
insertNode(parent, a);
|
|
61
|
+
insertNode(parent, b);
|
|
62
|
+
expect(parent.children).toEqual([a, b]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('inserts before the anchor', () => {
|
|
66
|
+
const parent = createElement('container');
|
|
67
|
+
const a = createElement('sprite');
|
|
68
|
+
const b = createElement('sprite');
|
|
69
|
+
const c = createElement('sprite');
|
|
70
|
+
insertNode(parent, a);
|
|
71
|
+
insertNode(parent, b);
|
|
72
|
+
insertNode(parent, c, b);
|
|
73
|
+
expect(parent.children).toEqual([a, c, b]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('schedules a flex re-measure when the parent is a FlexContainer', () => {
|
|
77
|
+
const parent = createElement('flexContainer');
|
|
78
|
+
const child = createElement('text');
|
|
79
|
+
insertNode(parent, child);
|
|
80
|
+
|
|
81
|
+
// FlexContainer defers its own layout pass through the ticker too, so drain
|
|
82
|
+
// everything that got queued rather than assuming a single callback.
|
|
83
|
+
expect(app.ticker.addOnce).toHaveBeenCalled();
|
|
84
|
+
expect(app.layout._updateSize).not.toHaveBeenCalled();
|
|
85
|
+
for (const [fn] of app.ticker.addOnce.mock.calls) fn();
|
|
86
|
+
|
|
87
|
+
expect(app.layout._updateSize).toHaveBeenCalledWith(parent);
|
|
88
|
+
expect(app.layout.updateLayout).toHaveBeenCalledWith(parent);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('removes and destroys the child', () => {
|
|
92
|
+
const parent = createElement('container');
|
|
93
|
+
const child = createElement('container');
|
|
94
|
+
const grandchild = createElement('sprite');
|
|
95
|
+
insertNode(child, grandchild);
|
|
96
|
+
insertNode(parent, child);
|
|
97
|
+
|
|
98
|
+
// `removeNode` is not part of the exported runtime; it is reached through the
|
|
99
|
+
// reconciler, so drive it with a signal that swaps the child out.
|
|
100
|
+
const [show, setShow] = createSignal(true);
|
|
101
|
+
const holder = createElement('container');
|
|
102
|
+
createRoot(() => insert(holder, () => (show() ? child : null)));
|
|
103
|
+
expect(holder.children).toEqual([child]);
|
|
104
|
+
|
|
105
|
+
setShow(false);
|
|
106
|
+
expect(holder.children).toEqual([]);
|
|
107
|
+
expect(child.destroyed).toBe(true);
|
|
108
|
+
expect(grandchild.destroyed).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('setProperty', () => {
|
|
113
|
+
it('expands a number into both axes of a point prop', () => {
|
|
114
|
+
const node = createElement('sprite');
|
|
115
|
+
setProp(node, 'scale', 2);
|
|
116
|
+
expect(node.scale.x).toBe(2);
|
|
117
|
+
expect(node.scale.y).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('accepts { x, y } objects', () => {
|
|
121
|
+
const node = createElement('sprite');
|
|
122
|
+
setProp(node, 'position', { x: 10, y: 20 });
|
|
123
|
+
setProp(node, 'scale', { x: 3, y: 4 });
|
|
124
|
+
setProp(node, 'pivot', { x: 5, y: 6 });
|
|
125
|
+
expect([node.x, node.y]).toEqual([10, 20]);
|
|
126
|
+
expect([node.scale.x, node.scale.y]).toEqual([3, 4]);
|
|
127
|
+
expect([node.pivot.x, node.pivot.y]).toEqual([5, 6]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('sets anchor on sprites and text', () => {
|
|
131
|
+
const sprite = createElement('sprite') as Sprite;
|
|
132
|
+
const text = createElement('text') as Text;
|
|
133
|
+
setProp(sprite, 'anchor', 0.5);
|
|
134
|
+
setProp(text, 'anchor', { x: 0, y: 1 });
|
|
135
|
+
expect([sprite.anchor.x, sprite.anchor.y]).toEqual([0.5, 0.5]);
|
|
136
|
+
expect([text.anchor.x, text.anchor.y]).toEqual([0, 1]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('passes unknown props straight through', () => {
|
|
140
|
+
const node = createElement('container');
|
|
141
|
+
setProp(node, 'alpha', 0.25);
|
|
142
|
+
setProp(node, 'visible', false);
|
|
143
|
+
setProp(node, 'x', 12);
|
|
144
|
+
expect(node.alpha).toBe(0.25);
|
|
145
|
+
expect(node.visible).toBe(false);
|
|
146
|
+
expect(node.x).toBe(12);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('re-measures a flex child when a plain prop changes', () => {
|
|
150
|
+
const parent = createElement('flexContainer');
|
|
151
|
+
const child = createElement('text');
|
|
152
|
+
insertNode(parent, child);
|
|
153
|
+
app.layout._updateSize.mockClear();
|
|
154
|
+
|
|
155
|
+
setProp(child, 'text', 'hello');
|
|
156
|
+
|
|
157
|
+
expect(app.layout._updateSize).toHaveBeenCalledWith(parent);
|
|
158
|
+
expect(app.layout.updateLayout).toHaveBeenCalledWith(parent);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('wires on* props as listeners and enables events', () => {
|
|
162
|
+
const node = createElement('text');
|
|
163
|
+
const first = vi.fn();
|
|
164
|
+
setProp(node, 'onPointerTap', first);
|
|
165
|
+
|
|
166
|
+
expect(node.listenerCount('pointertap')).toBe(1);
|
|
167
|
+
expect(node.eventMode).toBe('static');
|
|
168
|
+
|
|
169
|
+
node.emit('pointertap' as any, 'evt' as any);
|
|
170
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
171
|
+
|
|
172
|
+
const second = vi.fn();
|
|
173
|
+
setProp(node, 'onPointerTap', second, first);
|
|
174
|
+
expect(node.listenerCount('pointertap')).toBe(1);
|
|
175
|
+
|
|
176
|
+
node.emit('pointertap' as any, 'evt' as any);
|
|
177
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
178
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('lets an explicit eventMode prop win over the text default', () => {
|
|
182
|
+
const node = createElement('text');
|
|
183
|
+
setProp(node, 'eventMode', 'static');
|
|
184
|
+
expect(node.eventMode).toBe('static');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('draw', () => {
|
|
188
|
+
it('invokes the callback with the graphics node', () => {
|
|
189
|
+
const node = createElement('graphics') as Graphics;
|
|
190
|
+
const draw = vi.fn();
|
|
191
|
+
setProp(node, 'draw', draw);
|
|
192
|
+
expect(draw).toHaveBeenCalledWith(node);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('clears before re-running a changed callback', () => {
|
|
196
|
+
const node = createElement('graphics') as Graphics;
|
|
197
|
+
const clear = vi.spyOn(node, 'clear');
|
|
198
|
+
const first = vi.fn();
|
|
199
|
+
const second = vi.fn();
|
|
200
|
+
|
|
201
|
+
setProp(node, 'draw', first);
|
|
202
|
+
setProp(node, 'draw', second, first);
|
|
203
|
+
|
|
204
|
+
expect(clear).toHaveBeenCalledTimes(2);
|
|
205
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(second).toHaveBeenCalledWith(node);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('throws when the target is not a graphics node', () => {
|
|
210
|
+
const node = createElement('container');
|
|
211
|
+
expect(() => setProp(node, 'draw', () => {})).toThrow(/only valid on <graphics>/);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('text nodes', () => {
|
|
217
|
+
it('creates JSX text nodes with events off and updates them in place', () => {
|
|
218
|
+
const node = createTextNode('hello') as Text;
|
|
219
|
+
expect(node).toBeInstanceOf(Text);
|
|
220
|
+
expect(node.text).toBe('hello');
|
|
221
|
+
expect(node.eventMode).toBe('none');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('distinguishes JSX text nodes from <text> elements', () => {
|
|
225
|
+
expect((createTextNode('hello') as any).__isTextNode).toBe(true);
|
|
226
|
+
expect((createElement('text') as any).__isTextNode).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('replaces the text of an existing JSX text node rather than remounting', () => {
|
|
230
|
+
const parent = createElement('container');
|
|
231
|
+
const [label, setLabel] = createSignal('one');
|
|
232
|
+
createRoot(() => insert(parent, () => label()));
|
|
233
|
+
|
|
234
|
+
const node = parent.children[0] as Text;
|
|
235
|
+
expect(node.text).toBe('one');
|
|
236
|
+
|
|
237
|
+
setLabel('two');
|
|
238
|
+
expect(parent.children[0]).toBe(node);
|
|
239
|
+
expect(node.text).toBe('two');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('render', () => {
|
|
244
|
+
it('keeps props reactive and stops updating after dispose', () => {
|
|
245
|
+
const root = createElement('container');
|
|
246
|
+
const [alpha, setAlpha] = createSignal(0.25);
|
|
247
|
+
|
|
248
|
+
const dispose = render(() => {
|
|
249
|
+
const node = createElement('sprite');
|
|
250
|
+
spread(
|
|
251
|
+
node,
|
|
252
|
+
{
|
|
253
|
+
get alpha() {
|
|
254
|
+
return alpha();
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
true,
|
|
258
|
+
);
|
|
259
|
+
return node;
|
|
260
|
+
}, root);
|
|
261
|
+
|
|
262
|
+
const node = root.children[0] as Sprite;
|
|
263
|
+
expect(node.alpha).toBe(0.25);
|
|
264
|
+
|
|
265
|
+
setAlpha(0.5);
|
|
266
|
+
expect(node.alpha).toBe(0.5);
|
|
267
|
+
|
|
268
|
+
dispose();
|
|
269
|
+
setAlpha(1);
|
|
270
|
+
expect(node.alpha).toBe(0.5);
|
|
271
|
+
});
|
|
272
|
+
});
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// A SolidJS "universal renderer" whose host nodes are Pixi display objects.
|
|
2
|
+
//
|
|
3
|
+
// This file plays the role of framework internals, so raw Pixi is fine here.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Application,
|
|
7
|
+
FlexContainer,
|
|
8
|
+
resolveAnchor,
|
|
9
|
+
resolvePivot,
|
|
10
|
+
resolvePosition,
|
|
11
|
+
resolveScale,
|
|
12
|
+
resolveUnknownKeys,
|
|
13
|
+
} from '@caperjs/core';
|
|
14
|
+
import { Container, Graphics, Text } from 'pixi.js';
|
|
15
|
+
import { createRenderer } from 'solid-js/universal';
|
|
16
|
+
|
|
17
|
+
import { createCatalogNode } from './catalog';
|
|
18
|
+
|
|
19
|
+
export type PixiNode = Container;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Re-measure and relayout a FlexContainer child whose content just changed.
|
|
23
|
+
*
|
|
24
|
+
* Solid inserts a node first and sets `text` / `style` after, so yoga measures
|
|
25
|
+
* the child while it is still empty and caches a zero-width intrinsic size — the
|
|
26
|
+
* child then renders scaled to nothing. `FlexContainer.updateLayout()` alone
|
|
27
|
+
* doesn't fix it: `@pixi/layout` throttles its intrinsic-size pass to 100ms, and
|
|
28
|
+
* caper runs the layout system with `autoUpdate: false`, so nothing ever
|
|
29
|
+
* re-measures. Drive that pass directly instead.
|
|
30
|
+
*/
|
|
31
|
+
function invalidateFlexChild(node: any) {
|
|
32
|
+
const parent = node.parent;
|
|
33
|
+
if (!(parent instanceof FlexContainer)) return;
|
|
34
|
+
node.layout?.forceUpdate?.();
|
|
35
|
+
const layoutSystem = Application.getInstance().renderer.layout as any;
|
|
36
|
+
layoutSystem._updateSize(parent);
|
|
37
|
+
layoutSystem.updateLayout(parent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const renderer = createRenderer<PixiNode>({
|
|
41
|
+
createElement(tag: string): PixiNode {
|
|
42
|
+
const node = createCatalogNode(tag);
|
|
43
|
+
// Text is decoration by default — opting into events is what `on*` props (or
|
|
44
|
+
// an explicit `eventMode` prop, applied after creation) are for.
|
|
45
|
+
if (node instanceof Text) node.eventMode = 'none';
|
|
46
|
+
return node;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
createTextNode(value: string): PixiNode {
|
|
50
|
+
const node = new Text({ text: String(value) });
|
|
51
|
+
node.eventMode = 'none';
|
|
52
|
+
// Marks this as a JSX text node so `<text>` elements aren't mistaken for one.
|
|
53
|
+
(node as any).__isTextNode = true;
|
|
54
|
+
return node;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
replaceText(node: PixiNode, value: string) {
|
|
58
|
+
(node as Text).text = String(value);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
isTextNode(node: PixiNode) {
|
|
62
|
+
return node instanceof Text && (node as any).__isTextNode === true;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
setProperty(node: any, name: string, value: any, prev?: any) {
|
|
66
|
+
if (name === 'draw') {
|
|
67
|
+
if (!(node instanceof Graphics)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`[@caperjs/solid] The \`draw\` prop is only valid on <graphics>, received it on ${node?.constructor?.name}.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
node.clear();
|
|
73
|
+
value?.(node);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (name.startsWith('on') && typeof value === 'function') {
|
|
78
|
+
const ev = name.slice(2).toLowerCase();
|
|
79
|
+
if (prev) node.off(ev, prev);
|
|
80
|
+
node.on(ev, value);
|
|
81
|
+
node.eventMode = 'static';
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Point-like props go through the same resolvers `this.add.*` uses, so
|
|
86
|
+
// `scale={2}`, `scale={{ x, y }}` and `scale={[x, y]}` behave identically in
|
|
87
|
+
// JSX and in factory config.
|
|
88
|
+
switch (name) {
|
|
89
|
+
case 'position':
|
|
90
|
+
resolvePosition({ position: value }, node);
|
|
91
|
+
return;
|
|
92
|
+
case 'scale':
|
|
93
|
+
resolveScale({ scale: value }, node);
|
|
94
|
+
return;
|
|
95
|
+
case 'pivot':
|
|
96
|
+
resolvePivot(value, node);
|
|
97
|
+
return;
|
|
98
|
+
case 'anchor':
|
|
99
|
+
resolveAnchor(value, node);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resolveUnknownKeys({ [name]: value }, node);
|
|
104
|
+
invalidateFlexChild(node);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
insertNode(parent: PixiNode, node: PixiNode, anchor?: PixiNode) {
|
|
108
|
+
if (anchor) {
|
|
109
|
+
parent.addChildAt(node, parent.getChildIndex(anchor));
|
|
110
|
+
} else {
|
|
111
|
+
parent.addChild(node);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Solid may set a node's props before inserting it, so re-measure on the next
|
|
115
|
+
// tick — by then the whole tree has been built and every prop is applied.
|
|
116
|
+
if (parent instanceof FlexContainer) {
|
|
117
|
+
Application.getInstance().ticker.addOnce(() => invalidateFlexChild(node));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
removeNode(parent: PixiNode, node: PixiNode) {
|
|
122
|
+
parent.removeChild(node);
|
|
123
|
+
node.destroy({ children: true });
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
getParentNode(node: PixiNode) {
|
|
127
|
+
return node.parent ?? undefined;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
getFirstChild(node: PixiNode) {
|
|
131
|
+
return node.children[0] as PixiNode | undefined;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
getNextSibling(node: PixiNode) {
|
|
135
|
+
const parent = node.parent;
|
|
136
|
+
if (!parent) return undefined;
|
|
137
|
+
return parent.children[parent.getChildIndex(node) + 1] as PixiNode | undefined;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The babel universal transform imports its runtime from this module.
|
|
142
|
+
export const {
|
|
143
|
+
render,
|
|
144
|
+
effect,
|
|
145
|
+
memo,
|
|
146
|
+
createComponent,
|
|
147
|
+
createElement,
|
|
148
|
+
createTextNode,
|
|
149
|
+
insertNode,
|
|
150
|
+
insert,
|
|
151
|
+
spread,
|
|
152
|
+
setProp,
|
|
153
|
+
mergeProps,
|
|
154
|
+
use,
|
|
155
|
+
} = renderer;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Application } from '@caperjs/core';
|
|
2
|
+
import { createRoot } from 'solid-js';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { useTick } from './useTick';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
(Application as any).instance = undefined;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('useTick', () => {
|
|
12
|
+
it('adds the callback to the ticker and removes it on cleanup', () => {
|
|
13
|
+
const ticker = { add: vi.fn(), remove: vi.fn(), addOnce: vi.fn() };
|
|
14
|
+
(Application as any).instance = { config: {}, ticker };
|
|
15
|
+
|
|
16
|
+
const fn = vi.fn();
|
|
17
|
+
const dispose = createRoot((d) => {
|
|
18
|
+
useTick(fn);
|
|
19
|
+
return d;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(ticker.add).toHaveBeenCalledWith(fn);
|
|
23
|
+
expect(ticker.remove).not.toHaveBeenCalled();
|
|
24
|
+
|
|
25
|
+
dispose();
|
|
26
|
+
expect(ticker.remove).toHaveBeenCalledWith(fn);
|
|
27
|
+
});
|
|
28
|
+
});
|