canvasengine 2.0.0-beta.60 → 2.0.0-beta.61

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasengine",
3
- "version": "2.0.0-beta.60",
3
+ "version": "2.0.0-beta.61",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@barvynkoa/particle-emitter": "^0.0.1",
12
+ "@chenglou/pretext": "^0.0.6",
12
13
  "@pixi/layout": "^3.2.0",
13
14
  "@signe/reactive": "^2.9.0",
14
15
  "howler": "^2.2.4",
@@ -91,7 +91,7 @@ export const Canvas: ComponentFunction<CanvasProps> = async (props = {}) => {
91
91
 
92
92
  if (props.tickStart !== false) canvasElement.directives.tick.start()
93
93
 
94
- effect(() => {
94
+ const renderEffect = effect(() => {
95
95
  canvasElement.propObservables!.tick();
96
96
  renderer.render(canvasElement.componentInstance as any);
97
97
  });
@@ -107,7 +107,7 @@ export const Canvas: ComponentFunction<CanvasProps> = async (props = {}) => {
107
107
 
108
108
  canvasSize.set({ width: app.screen.width, height: app.screen.height })
109
109
 
110
- app.renderer.on('resize', (width: number, height: number) => {
110
+ const resizeHandler = (width: number, height: number) => {
111
111
  canvasSize.set({ width, height });
112
112
 
113
113
  if (app.stage.layout) {
@@ -116,33 +116,48 @@ export const Canvas: ComponentFunction<CanvasProps> = async (props = {}) => {
116
116
  height
117
117
  }
118
118
  }
119
- });
119
+ };
120
+
121
+ app.renderer.on('resize', resizeHandler);
120
122
 
121
123
  if (props.tickStart !== false) canvasElement.directives.tick.start();
122
124
 
123
- app.ticker.add(() => {
125
+ const tickerHandler = () => {
124
126
  canvasElement.propObservables!.tick();
125
- });
127
+ };
128
+
129
+ app.ticker.add(tickerHandler);
126
130
 
131
+ let cursorEffect: any;
127
132
  if (cursorStyles) {
128
- effect(() => {
133
+ cursorEffect = effect(() => {
129
134
  renderer.events.cursorStyles = cursorStyles();
130
135
  });
131
136
  }
132
137
 
138
+ let classEffect: any;
133
139
  if (className) {
134
- effect(() => {
140
+ classEffect = effect(() => {
135
141
  canvasEl.classList.add(className());
136
142
  });
137
143
  }
138
144
 
139
145
  const existingCanvas = rootElement.querySelector("canvas");
140
- if (existingCanvas) {
146
+ if (existingCanvas && existingCanvas !== canvasEl) {
141
147
  rootElement.replaceChild(canvasEl, existingCanvas);
142
- } else {
148
+ } else if (!existingCanvas) {
143
149
  rootElement.appendChild(canvasEl);
144
150
  }
145
151
 
152
+ canvasElement.effectUnmounts.push(() => {
153
+ renderEffect?.subscription?.unsubscribe?.();
154
+ cursorEffect?.subscription?.unsubscribe?.();
155
+ classEffect?.subscription?.unsubscribe?.();
156
+ app.ticker.remove(tickerHandler);
157
+ app.renderer.off?.('resize', resizeHandler);
158
+ canvasElement.directives.tick?.stop();
159
+ });
160
+
146
161
  options.context!.app.set(app)
147
162
  };
148
163
 
@@ -395,6 +395,9 @@ export function DisplayObject(extendClass) {
395
395
  await this.onBeforeDestroy();
396
396
  }
397
397
  if (afterDestroy) afterDestroy();
398
+ if (this.parent && typeof this.parent.removeChild === "function") {
399
+ this.parent.removeChild(this);
400
+ }
398
401
  super.destroy();
399
402
  }
400
403
 
@@ -184,6 +184,8 @@ const graphicsAnchor = (anchor, width, height) => {
184
184
  return { x: -ax * width, y: -ay * height };
185
185
  }
186
186
 
187
+ const propValue = (value: any) => isSignal(value) ? value() : value;
188
+
187
189
  export function Rect(props: RectProps) {
188
190
  const { color, borderRadius, border } = useProps(props, {
189
191
  borderRadius: null,
@@ -198,10 +200,11 @@ export function Rect(props: RectProps) {
198
200
  } else {
199
201
  g.rect(x, y, width, height);
200
202
  }
201
- if (border) {
202
- g.stroke(border);
203
+ const borderValue = propValue(border);
204
+ if (borderValue) {
205
+ g.stroke(borderValue);
203
206
  }
204
- g.fill(color());
207
+ g.fill(propValue(color));
205
208
  },
206
209
  ...props
207
210
  })
@@ -216,14 +219,15 @@ export function Circle(props: CircleProps) {
216
219
  draw: (g, width, height, anchor) => {
217
220
  const { x, y } = graphicsAnchor(anchor, width, height);
218
221
  if (width == height || height == 0) {
219
- g.circle(x, y, radius() || width);
222
+ g.circle(x, y, propValue(radius) || width);
220
223
  } else {
221
224
  g.ellipse(x, y, width, height);
222
225
  }
223
- if (border()) {
224
- g.stroke(border());
226
+ const borderValue = propValue(border);
227
+ if (borderValue) {
228
+ g.stroke(borderValue);
225
229
  }
226
- g.fill(color());
230
+ g.fill(propValue(color));
227
231
  },
228
232
  ...props
229
233
  })
@@ -245,9 +249,10 @@ export function Triangle(props: TriangleProps) {
245
249
  g.lineTo(x + gWidth / 2, y);
246
250
  g.lineTo(x + gWidth, y + gHeight);
247
251
  g.lineTo(x, y + gHeight);
248
- g.fill(color());
249
- if (border) {
250
- g.stroke(border);
252
+ g.fill(propValue(color));
253
+ const borderValue = propValue(border);
254
+ if (borderValue) {
255
+ g.stroke(borderValue);
251
256
  }
252
257
  },
253
258
  ...props
@@ -305,4 +310,4 @@ export function Svg(props: SvgProps) {
305
310
  },
306
311
  ...props
307
312
  })
308
- }
313
+ }
@@ -1,6 +1,7 @@
1
+ import { layout as layoutPretext, prepare as preparePretext, type PreparedText, type PrepareOptions } from "@chenglou/pretext";
1
2
  import { Text as PixiText, TextStyle } from "pixi.js";
2
- import { createComponent, registerComponent, Element, Props } from "../engine/reactive";
3
- import { DisplayObject, ComponentInstance } from "./DisplayObject";
3
+ import { createComponent, registerComponent, Element } from "../engine/reactive";
4
+ import { DisplayObject } from "./DisplayObject";
4
5
  import { DisplayObjectProps } from "./types/DisplayObject";
5
6
  import { Signal } from "@signe/reactive";
6
7
  import { on, isTrigger } from "../engine/trigger";
@@ -14,7 +15,7 @@ export interface TextProps extends DisplayObjectProps {
14
15
  text?: string;
15
16
  style?: Partial<TextStyle>;
16
17
  color?: string;
17
- size?: string;
18
+ size?: string | number;
18
19
  fontFamily?: string;
19
20
  typewriter?: {
20
21
  speed?: number;
@@ -30,6 +31,22 @@ export interface TextProps extends DisplayObjectProps {
30
31
  context?: any; // Ensure context is available, ideally typed from a base prop or injected
31
32
  }
32
33
 
34
+ type PretextMeasurement = {
35
+ width: number;
36
+ height: number;
37
+ };
38
+
39
+ const toFiniteNumber = (value: unknown): number | null => {
40
+ if (typeof value === "number") {
41
+ return Number.isFinite(value) ? value : null;
42
+ }
43
+ if (typeof value === "string") {
44
+ const parsed = Number.parseFloat(value);
45
+ return Number.isFinite(parsed) ? parsed : null;
46
+ }
47
+ return null;
48
+ };
49
+
33
50
  class CanvasText extends DisplayObject(PixiText) {
34
51
  private subscriptionTick: any;
35
52
  private fullText: string = "";
@@ -41,6 +58,9 @@ class CanvasText extends DisplayObject(PixiText) {
41
58
  private typewriterSound?: Howl;
42
59
  private lastSoundTime: number = 0;
43
60
  private soundDuration: number = 0; // Duration of the sound in milliseconds
61
+ private pretextPrepared: PreparedText | null = null;
62
+ private pretextPrepareKey: string = "";
63
+ private measuredLayout: PretextMeasurement | null = null;
44
64
 
45
65
  /**
46
66
  * Called when the component is mounted to the scene graph.
@@ -102,12 +122,7 @@ class CanvasText extends DisplayObject(PixiText) {
102
122
  this.updateLayout();
103
123
  }
104
124
  if (props.style) {
105
- for (const key in props.style) {
106
- this.style[key] = props.style[key];
107
- }
108
- if (props.style.wordWrapWidth) {
109
- this._wordWrapWidth = props.style.wordWrapWidth;
110
- }
125
+ this.applyTextStyle(props.style);
111
126
  }
112
127
  if (props.color) {
113
128
  this.style.fill = props.color;
@@ -118,6 +133,7 @@ class CanvasText extends DisplayObject(PixiText) {
118
133
  if (props.fontFamily) {
119
134
  this.style.fontFamily = props.fontFamily;
120
135
  }
136
+ this.updateWordWrapWidth();
121
137
 
122
138
  // Use the centralized layout update method
123
139
  this.updateLayout();
@@ -171,12 +187,130 @@ class CanvasText extends DisplayObject(PixiText) {
171
187
  * This method ensures consistent width, height and word wrap behavior.
172
188
  */
173
189
  private updateLayout() {
174
- if (this._wordWrapWidth) {
175
- this.setWidth(this._wordWrapWidth);
176
- } else {
177
- this.setWidth(this.width);
190
+ const measured = this.measurePretextLayout();
191
+ const width = measured?.width ?? this.width;
192
+ const height = measured?.height ?? this.height;
193
+
194
+ this.measuredLayout = measured ?? { width, height };
195
+ this.setMeasuredLayout(width, height);
196
+ }
197
+
198
+ private applyTextStyle(style: Partial<TextStyle>) {
199
+ const assign = (this.style as TextStyle & { assign?: (values: any) => TextStyle }).assign;
200
+ if (typeof assign === "function") {
201
+ assign.call(this.style, style);
202
+ return;
203
+ }
204
+
205
+ for (const key in style) {
206
+ (this.style as any)[key] = (style as any)[key];
207
+ }
208
+ }
209
+
210
+ private updateWordWrapWidth() {
211
+ if (!this.style.wordWrap) {
212
+ this._wordWrapWidth = 0;
213
+ return;
214
+ }
215
+ const wordWrapWidth = toFiniteNumber(this.style.wordWrapWidth);
216
+ this._wordWrapWidth = wordWrapWidth !== null && wordWrapWidth > 0 ? wordWrapWidth : 0;
217
+ }
218
+
219
+ private measurePretextLayout(): PretextMeasurement | null {
220
+ if (!this.style.wordWrap || this._wordWrapWidth <= 0) {
221
+ this.pretextPrepared = null;
222
+ this.pretextPrepareKey = "";
223
+ return null;
224
+ }
225
+
226
+ const text = `${this.text ?? ""}`;
227
+ const font = this.resolvePretextFont();
228
+ const lineHeight = this.resolveLineHeight();
229
+ const options = this.resolvePretextOptions();
230
+ const prepareKey = JSON.stringify([text, font, options.whiteSpace, options.wordBreak, options.letterSpacing]);
231
+
232
+ try {
233
+ if (this.pretextPrepareKey !== prepareKey || !this.pretextPrepared) {
234
+ this.pretextPrepared = preparePretext(text, font, options);
235
+ this.pretextPrepareKey = prepareKey;
236
+ }
237
+
238
+ const result = layoutPretext(this.pretextPrepared, this._wordWrapWidth, lineHeight);
239
+ return {
240
+ width: this._wordWrapWidth,
241
+ height: result.height,
242
+ };
243
+ } catch {
244
+ this.pretextPrepared = null;
245
+ this.pretextPrepareKey = "";
246
+ return null;
247
+ }
248
+ }
249
+
250
+ private resolvePretextFont(): string {
251
+ const fontString = (this.style as TextStyle & { _fontString?: string })._fontString;
252
+ if (fontString) return fontString;
253
+
254
+ const fontSize = this.resolveFontSize();
255
+ const fontFamily = Array.isArray(this.style.fontFamily)
256
+ ? this.style.fontFamily.join(",")
257
+ : this.style.fontFamily;
258
+
259
+ return `${this.style.fontStyle} ${this.style.fontVariant} ${this.style.fontWeight} ${fontSize}px ${fontFamily}`;
260
+ }
261
+
262
+ private resolvePretextOptions(): PrepareOptions {
263
+ return {
264
+ whiteSpace: this.style.whiteSpace === "normal" ? "normal" : "pre-wrap",
265
+ letterSpacing: this.style.letterSpacing || undefined,
266
+ };
267
+ }
268
+
269
+ private resolveLineHeight(): number {
270
+ const lineHeight = toFiniteNumber(this.style.lineHeight);
271
+ if (lineHeight !== null && lineHeight > 0) return lineHeight;
272
+ return this.resolveFontSize();
273
+ }
274
+
275
+ private resolveFontSize(): number {
276
+ const fontSize = toFiniteNumber(this.style.fontSize);
277
+ return fontSize !== null && fontSize > 0 ? fontSize : 16;
278
+ }
279
+
280
+ private setMeasuredLayout(width: number, height: number) {
281
+ const layout: { width?: number; height?: number } = {};
282
+
283
+ if (this.fullProps.width === undefined) {
284
+ this.displayWidth.set(width);
285
+ if (this.parentIsFlex) {
286
+ layout.width = width;
287
+ }
288
+ }
289
+
290
+ if (this.fullProps.height === undefined) {
291
+ this.displayHeight.set(height);
292
+ if (this.parentIsFlex) {
293
+ layout.height = height;
294
+ }
295
+ }
296
+
297
+ if (this.parentIsFlex && (layout.width !== undefined || layout.height !== undefined)) {
298
+ (this as any).layout = layout;
299
+ }
300
+ }
301
+
302
+ getWidth(): number {
303
+ if (this.fullProps.width === undefined && this.measuredLayout) {
304
+ return this.measuredLayout.width;
305
+ }
306
+ return super.getWidth();
307
+ }
308
+
309
+ getHeight(): number {
310
+ if (this.fullProps.height === undefined && this.measuredLayout) {
311
+ return this.measuredLayout.height;
178
312
  }
179
- this.setHeight(this.height);
313
+ return super.getHeight();
180
314
  }
181
315
 
182
316
  private typewriterEffect() {
@@ -235,6 +369,9 @@ class CanvasText extends DisplayObject(PixiText) {
235
369
  this.typewriterSound.unload();
236
370
  this.typewriterSound = undefined;
237
371
  }
372
+ this.pretextPrepared = null;
373
+ this.pretextPrepareKey = "";
374
+ this.measuredLayout = null;
238
375
  if (afterDestroy) {
239
376
  afterDestroy();
240
377
  }
@@ -19,13 +19,16 @@ export class Scheduler extends Directive {
19
19
  private requestedDelay: number = 0
20
20
  private lastTimestamp: number = 0
21
21
  private _stop: boolean = false
22
+ private running: boolean = false
22
23
  private tick: WritableSignal<Tick | null>
23
24
 
24
25
  onInit(element: Element) {
25
26
  this.tick = element.propObservables?.tick as any
26
27
  }
27
28
 
28
- onDestroy() { }
29
+ onDestroy() {
30
+ this.stop()
31
+ }
29
32
  onMount(element: Element) { }
30
33
  onUpdate(props: any) { }
31
34
 
@@ -59,6 +62,9 @@ export class Scheduler extends Directive {
59
62
  fps?: number,
60
63
  delay?: number
61
64
  } = {}) {
65
+ if (this.running) return this
66
+ this._stop = false
67
+ this.running = true
62
68
  if (options.maxFps) this.maxFps = options.maxFps
63
69
  if (options.fps) this.fps = options.fps
64
70
  if (options.delay) this.requestedDelay = options.delay
@@ -76,6 +82,7 @@ export class Scheduler extends Directive {
76
82
 
77
83
  if (!this.maxFps) {
78
84
  const loop = (timestamp: number) => {
85
+ if (this._stop) return
79
86
  requestAnimationFrame(loop)
80
87
  this.nextTick(timestamp)
81
88
  }
@@ -103,6 +110,7 @@ export class Scheduler extends Directive {
103
110
 
104
111
  stop() {
105
112
  this._stop = true
113
+ this.running = false
106
114
  }
107
115
  }
108
116
 
@@ -1,4 +1,5 @@
1
1
  import { Application, ApplicationOptions } from "pixi.js";
2
+ import { Observable, Subscription } from "rxjs";
2
3
  import { ComponentFunction, h } from "./signal";
3
4
  import { useProps } from '../hooks/useProps';
4
5
  import { registerAllComponents, registerComponent } from './reactive';
@@ -33,6 +34,12 @@ export interface BootstrapOptions extends ApplicationOptions {
33
34
  enableLayout?: boolean; // true by default
34
35
  }
35
36
 
37
+ type BootstrapResult = {
38
+ canvasElement: any;
39
+ app: Application;
40
+ hmrSubscription?: Subscription;
41
+ };
42
+
36
43
  /**
37
44
  * Bootstraps a canvas element and renders it to the DOM.
38
45
  *
@@ -61,7 +68,7 @@ export interface BootstrapOptions extends ApplicationOptions {
61
68
  * });
62
69
  * ```
63
70
  */
64
- export const bootstrapCanvas = async (rootElement: HTMLElement | null, canvas: ComponentFunction<any>, options?: BootstrapOptions) => {
71
+ export const bootstrapCanvas = async (rootElement: HTMLElement | null, canvas: ComponentFunction<any>, options?: BootstrapOptions): Promise<BootstrapResult> => {
65
72
  // Extract component registration options
66
73
  const { components, autoRegister, enableLayout, ...appOptions } = options ?? {};
67
74
  if (enableLayout !== false) {
@@ -90,20 +97,57 @@ export const bootstrapCanvas = async (rootElement: HTMLElement | null, canvas: C
90
97
  antialias: true,
91
98
  ...appOptions
92
99
  });
93
- const canvasElement = await h(canvas);
94
- if (canvasElement.tag != 'Canvas') {
95
- throw new Error('Canvas is required');
96
- }
97
- (canvasElement as any).render(rootElement, app);
98
100
 
99
- const { backgroundColor } = useProps(canvasElement.props, {
100
- backgroundColor: 'black'
101
- });
101
+ const renderCanvasElement = (canvasElement: any) => {
102
+ if (canvasElement.tag != 'Canvas') {
103
+ throw new Error('Canvas is required');
104
+ }
105
+ canvasElement.render(rootElement, app);
106
+
107
+ const { backgroundColor } = useProps(canvasElement.props, {
108
+ backgroundColor: 'black'
109
+ });
102
110
 
103
- app.renderer.background.color = backgroundColor()
111
+ app.renderer.background.color = backgroundColor()
104
112
 
105
- return {
106
- canvasElement,
107
- app
113
+ return {
114
+ canvasElement,
115
+ app
116
+ };
108
117
  };
118
+
119
+ const canvasElement = h(canvas) as any;
120
+
121
+ if (canvasElement instanceof Observable) {
122
+ return new Promise<BootstrapResult>((resolve, reject) => {
123
+ let resolved = false;
124
+ let hmrSubscription: Subscription;
125
+
126
+ hmrSubscription = canvasElement.subscribe({
127
+ next(value: any) {
128
+ try {
129
+ const nextCanvasElement = value?.elements?.[0] ?? value;
130
+ if (!nextCanvasElement) return;
131
+
132
+ const result = renderCanvasElement(nextCanvasElement);
133
+
134
+ if (!resolved) {
135
+ resolved = true;
136
+ Promise.resolve().then(() => {
137
+ resolve({
138
+ ...result,
139
+ hmrSubscription
140
+ });
141
+ });
142
+ }
143
+ } catch (error) {
144
+ reject(error);
145
+ }
146
+ },
147
+ error: reject
148
+ });
149
+ });
150
+ }
151
+
152
+ return renderCanvasElement(await canvasElement);
109
153
  };
@@ -294,7 +294,7 @@ function handleAnimatedSignalsFreeze(element: Element, shouldPause: boolean) {
294
294
  Object.values(element.propObservables).forEach(processValue);
295
295
  }
296
296
 
297
- function destroyElement(element: Element | Element[]) {
297
+ export function destroyElement(element: Element | Element[]) {
298
298
  if (Array.isArray(element)) {
299
299
  element.forEach((e) => destroyElement(e));
300
300
  return;